Skip to content
Open
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
5 changes: 5 additions & 0 deletions .opencode/skills/codenomad-architecture-guide/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,11 @@ description: |

## Package Map

External right-panel UI addons use `packages/server/src/panel-extensions/` and
`packages/ui/src/components/panel-extensions/`; see `dev-docs/PANEL_EXTENSIONS.md`.
They are sandboxed UI packages, not native OpenCode/backend plugins. Never import
author code into the primary renderer, expose generic RPC or grant native commands.

- `packages/server/`: Fastify control API, shared OpenCode service, locations, auth, filesystem, Git, Yolo, speech.
- `packages/ui/`: SolidJS application, generated client adapters, stores, components, i18n.
- `packages/electron-app/`: Electron host.
Expand Down
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# AGENT NOTES

## Styling Guidelines
- External panel extensions use `components/panel-extensions/` and `styles/panels/panel-extensions.css`. Never import author JavaScript into the main renderer or expose native/OpenCode bridges. ZIPs contain only a manifest and a self-contained HTML panel; install/replacement revokes activation. API versions belong to the public extension contract, not internal Solid modules. See `dev-docs/PANEL_EXTENSIONS.md` for distribution, consent, scope and sandbox limits.
- Provider accounts use a native dropdown like Web search defaults, without an Accounts disclosure or selectable account rows. Provider headers keep the name and text Manage models button on one row; a faint separator distinguishes the account controls below. Account actions are always visible, not hover-revealed. The `provider-account` container stacks the label above the dropdown/actions at narrow card widths. Native account ordering supplies the current account; rename/remove drafts stay keyed by credential. Environment connections are read-only. The browser never reads credential secrets. Native labels win; only `default` may display a sanitized Codex login from the owned server adapter. Opt-in Codex rotation runs before prompt/command admission, using fresh bounded quotas, local manual-change fences and no mutation/prompt replay; other providers remain manual. Account styling lives in `styles/components/provider-accounts.css`, backend policy in `provider-accounts/`, with limitations and isolated validation in `dev-docs/PROVIDER_ACCOUNTS_UX_PLAN.md`. Browser preview percentages remain simulated.
- Reuse the existing token & utility layers before introducing new CSS variables or custom properties. Extend `src/styles/tokens.css` / `src/styles/utilities.css` if a shared pattern is needed.
- Keep aggregate entry files (e.g., `src/styles/controls.css`, `messaging.css`, `panels.css`) lean—they should only `@import` feature-specific subfiles located inside `src/styles/{components|messaging|panels}`.
Expand Down
166 changes: 166 additions & 0 deletions dev-docs/PANEL_EXTENSIONS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
# External right-panel extensions — API 1

CodeNomad panel extensions are independently distributed **UI addons**, not
OpenCode plugins. Authors publish their own GitHub repositories and release ZIPs.
Users install the downloaded ZIP from **Customize right panel → Panel extensions**;
no CodeNomad rebuild or shared OpenCode service restart is involved.

This first distribution contract intentionally exposes only session identity and
appearance. It does **not** implement #801's gallery or grant transcript/image/file
reads. The later assets example must add a narrowly authorized, bounded image-read
capability; importing application stores or opening the generic RPC proxy is not
an extension API.

## Package layout

The ZIP contains exactly two UTF-8 files at its root:

```text
manifest.json
panel.html
```

`panel.html` is self-contained: inline JavaScript/CSS and optional data-URI assets.
Build with any framework outside CodeNomad, then bundle into that one HTML file.
There are no install scripts, npm installation, dependency fetching, Node modules,
server entrypoints or native binaries. Symlinks, extra files, nested paths,
duplicate ZIP entries, encrypted entries and invalid UTF-8 are rejected. Limits:
2 MiB ZIP, 4 KiB manifest, 2 MiB uncompressed HTML, 32 installed extensions,
16 MiB persisted catalogue, 128 explicit folder grants per extension.

```json
{
"id": "example.session",
"name": "Session example",
"version": "1.0.0",
"apiVersion": 1,
"author": "Example",
"license": "MIT",
"repository": "https://github.com/example/session",
"permissions": ["session.context"]
}
```

- ID: lowercase `namespace.name`, each segment 2–40 characters, letters/digits/
hyphens, starting with a letter. Use your GitHub account/organization namespace;
maintain the same ID across releases. The namespace is **not** verified ownership.
- Version: `major.minor.patch`, optionally `-prerelease`. Publish new code under a
new version; never replace the bytes of an already published release asset.
- API version: compatibility with the extension host, **not** the exact application
version. API 1 extensions work on hosts supporting API 1. An unsupported major
API or unknown permission is rejected, without executing author code.
- Author, name, license and repository are mandatory. Repository must be an HTTPS
GitHub repository URL, without credentials/query/fragment. Metadata is a claim,
not a signature. Use a real repository, SPDX license where possible, and include
that license's notice in the source and in the self-contained panel.
- Unknown manifest fields are rejected. Internal Solid interfaces/import paths are
not public compatibility promises. Additive API-1 evolution must preserve old
packages; breaking changes require a new API major and an explicit migration.

## Installation, updates and scope

Inspection shows the manifest and SHA-256 of the exact ZIP bytes before install.
The user acknowledges trust; installation starts **disabled**. Enable with either:

- **All projects:** every opened project on this CodeNomad backend/profile.
- **This folder:** the exact server-owned physical folder opened as the project.
It persists across closing/reopening, but does not infer sibling worktrees,
ancestor folders, another WSL distribution or a native project-ID family.

Both scopes live under the selected CodeNomad profile's `panel-extensions/`, not
inside a repository or the OpenCode discovery/database directories. In remote
access, installation affects that **server profile**, not the viewer's device.
Profiles/channels remain separate; a ZIP can be installed in each desired profile.
Global grants take precedence; turn off All projects before limiting to folders.

To update, download and inspect a new release ZIP and install it over the same ID.
Compare the displayed digest with the publisher's checksum through a trusted
channel. Replacing code revokes **all** activation grants, even when permissions
are unchanged. The old package remains intact on validation, conflict or storage
failure. Concurrent mutations compare exact digests. Removal is explicit and
removes the installed code plus its grants, not sessions or project data.

There is no marketplace, URL installer, silent update, automatic project discovery,
signature authority or remote-code startup hook. Keep trusted source/releases
available for audit; never regard a SHA-256 or a GitHub URL alone as proof of trust.

## Public browser API

The host supplies `window.codenomad` before author scripts run:

```js
const unsubscribe = codenomad.onContext(context => {
// { apiVersion: 1, sessionId: string | null, locale, appearance: "light" | "dark" }
document.querySelector("#session").textContent = context.sessionId ?? "—"
})
// codenomad.getContext() returns the latest context, or null before initialization.
// unsubscribe() removes this listener.
```

There is no instance URL, auth token, directory, prompt, transcript, credential,
filesystem handle, eval callback or native bridge in this API. Translate your panel
using `context.locale`; CodeNomad does not accept injected translation keys.
Honor appearance, keyboard navigation, accessible labels and square host chrome.
The panel can be unmounted whenever hidden, disconnected, disabled, replaced,
removed, or when the project/session changes. Treat DOM state as disposable.

Each mounting has a new one-use MessageChannel handshake bound to the injected
document. Parent window messages are not an RPC dispatcher. Late HTTP results and
old ports cannot initialize a different session or a reloaded/navigated document.

## Isolation and limits

Author code is never imported into the main renderer. It runs in an iframe with
`sandbox="allow-scripts"`: no same-origin, forms, popup, download or top-navigation
grant. A host-inserted CSP precedes author bytes and disallows network APIs,
external scripts/styles/images, nested frames, base URLs, objects and form targets.
Only inline scripts/styles and data/blob image assets are allowed. Windows Tauri
may inject native bridge objects into subframes; their presence is not permission.
The frame's opaque origin, CSP and native transport restrictions must prevent
their use. No app-native capabilities are passed through the extension API.

This is **not** a process/CPU isolation guarantee or an offline/safe-code guarantee.
A hostile approved author can hang its renderer and can try self-navigation;
browser navigation is not comprehensively blocked by CSP. A navigated document
cannot acquire the context handshake; it is detached on the next load. Install
only trusted authors and do not pass secrets to extensions. Desktop-native command
denial needs its own native qualification, not merely Chromium frame tests.

## Independent GitHub repository rules

Copy `examples/panel-extension/` into a separate repository, replace its example
identity/repository, and commit source, license, README, manifest and build recipe.
Publish a GitHub Release with:

1. A tag matching the manifest version, e.g. `v1.0.0`.
2. An immutable `namespace.name-1.0.0.zip` release asset and SHA-256 file.
3. Supported API major, requested permissions, changelog, maintainer and issue URL.
4. Reproducible build/test instructions and dependency licenses if bundling a UI.
5. Security reports handled by the extension author; never request credentials,
weaken the sandbox, depend on application internals or auto-run commands.

From the package folder, Python's standard library is enough:

```sh
python -m zipfile -c example.session-1.0.0.zip manifest.json panel.html
python -m zipfile -l example.session-1.0.0.zip
```

Publish this built asset, **not** GitHub's repository source ZIP (which has a parent
directory and other files). No central repository is required: author-owned repos
are supported. A curated index can later link to those immutable releases without
becoming an execution/install authority.

## Checks

```sh
node --import tsx --test packages/server/src/panel-extensions/extension.test.ts
node scripts/test-panel-extension-native.mjs # Windows, isolated Tauri/WebView2
# from packages/ui:
node --import tsx --test tests/browser/panel-extensions.test.ts
```

Server checks cover format/permission/API validation, ZIP bounds/path attacks,
durable scopes, replacement revocation, concurrent changes and corrupt-state
preservation. Rendered tests use the real right panel, installer, route handlers
and event dispatcher to exercise consent, lifecycle, stale reads and frame policy.
21 changes: 21 additions & 0 deletions examples/panel-extension/LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
MIT License

Copyright (c) CodeNomad contributors

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
26 changes: 26 additions & 0 deletions examples/panel-extension/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# Independent CodeNomad panel extension starter

Copy this folder to **your own GitHub repository**. Replace the `example.session`
identity, author and repository in `manifest.json`, and give your repository a
license. The sample is MIT-licensed. No CodeNomad source imports or build tool are
needed; it only displays the current session identifier, with English/French text
and appearance changes.

From this directory:

```sh
python -m zipfile -c example.session-1.0.0.zip manifest.json panel.html
python -c "import hashlib,pathlib; p=pathlib.Path('example.session-1.0.0.zip'); pathlib.Path(str(p)+'.sha256').write_text(hashlib.sha256(p.read_bytes()).hexdigest()+' '+p.name+'\n')"
```

Publish both files as assets of release `v1.0.0` on your repository. Users download
the ZIP, inspect/install it through the right-panel customization surface, then
enable it for All projects or This folder. GitHub's source-code ZIP is not the
installable package. New code requires a new release/version and explicit trust;
activation is revoked on replacement.

API compatibility is `apiVersion: 1`, not the exact CodeNomad version. This sample
cannot read messages/images or access files, OpenCode, native commands or network
APIs. It is a distribution smoke example, **not** the planned assets gallery.
See `dev-docs/PANEL_EXTENSIONS.md` in the CodeNomad repository for the public
contract, packaging rules, lifecycle and security limits.
10 changes: 10 additions & 0 deletions examples/panel-extension/manifest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"id": "example.session",
"name": "Session example",
"version": "1.0.0",
"apiVersion": 1,
"author": "Example",
"license": "MIT",
"repository": "https://github.com/example/session",
"permissions": ["session.context"]
}
18 changes: 18 additions & 0 deletions examples/panel-extension/panel.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<!-- SPDX-License-Identifier: MIT; example code, copyright CodeNomad contributors. -->
<style>
body { margin: 0; padding: 16px; font: 14px system-ui; color: #222; background: #fff; }
body[data-appearance="dark"] { color: #eee; background: #171717; }
p { overflow-wrap: anywhere; }
</style>
<h1 id="title"></h1>
<p id="session" aria-live="polite"></p>
<script>
codenomad.onContext(context => {
document.body.dataset.appearance = context.appearance
document.documentElement.lang = context.locale
document.documentElement.dir = context.locale === "he" ? "rtl" : "ltr"
const french = context.locale === "fr"
document.querySelector("#title").textContent = french ? "Session actuelle" : "Current session"
document.querySelector("#session").textContent = context.sessionId ?? (french ? "Aucune session" : "No session")
})
</script>
1 change: 1 addition & 0 deletions packages/server/src/api-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type {
RecentFolder,
} from "./config/schema"
import type { FormInfo, OpenCodeEvent, PermissionRequest } from "@opencode/client"
export type { PanelExtensionManifest, PanelExtensionSummary, PanelExtensionContext } from "./panel-extensions/contract"
export type { GitHistoryCommit, GitHistoryPage, GitCommitFile, GitCommitDetails, GitCommitDiff } from "./git-history-types"

/**
Expand Down
6 changes: 6 additions & 0 deletions packages/server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import path from "path"
import { fileURLToPath } from "url"
import { createRequire } from "module"
import { createHttpServer } from "./server/http-server"
import { PanelExtensionStore } from "./panel-extensions/store"
import { WorkspaceManager } from "./workspaces/manager"
import { resolveConfigLocation } from "./config/location"
import { SettingsService } from "./settings/service"
Expand Down Expand Up @@ -502,6 +503,9 @@ async function main() {
const httpBindHost = nativeParent.available ? "127.0.0.1" : options.http ? (options.https ? "127.0.0.1" : options.host) : "127.0.0.1"

const servers: Array<ReturnType<typeof createHttpServer>> = []
const panelExtensions = new PanelExtensionStore(path.join(configDir, "panel-extensions"), () => {
eventBus.publish({ type: "storage.stateChanged", owner: "panelExtensions", value: {} })
})

const httpServer = options.http || nativeParent.available
? createHttpServer({
Expand All @@ -523,6 +527,7 @@ async function main() {
remoteProxySessionManager,
yoloManager,
permissionReceipts,
panelExtensions,
uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR,
uiDevServerUrl: uiResolution.uiDevServerUrl,
logger,
Expand Down Expand Up @@ -552,6 +557,7 @@ async function main() {
remoteProxySessionManager,
yoloManager,
permissionReceipts,
panelExtensions,
uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR,
uiDevServerUrl: undefined,
logger,
Expand Down
27 changes: 27 additions & 0 deletions packages/server/src/panel-extensions/archive-fixture.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
// Deterministic, memory-only stored ZIPs for server and rendered browser regressions.
import { crc32 } from "node:zlib"
export function fixtureZip(files: { name: string; data: string | Buffer; mode?: number; size?: number }[]): Buffer {
const local: Buffer[] = [], central: Buffer[] = []
let offset = 0
for (const file of files) {
const name = Buffer.from(file.name), bytes = Buffer.from(file.data), size = file.size ?? bytes.length
const header = Buffer.alloc(30)
header.writeUInt32LE(0x04034b50); header.writeUInt16LE(20, 4)
header.writeUInt32LE(crc32(bytes), 14); header.writeUInt32LE(bytes.length, 18); header.writeUInt32LE(size, 22); header.writeUInt16LE(name.length, 26)
local.push(header, name, bytes)
const entry = Buffer.alloc(46)
entry.writeUInt32LE(0x02014b50); entry.writeUInt16LE(0x314, 4); entry.writeUInt16LE(20, 6)
entry.writeUInt32LE(crc32(bytes), 16); entry.writeUInt32LE(bytes.length, 20); entry.writeUInt32LE(size, 24); entry.writeUInt16LE(name.length, 28)
entry.writeUInt32LE(((file.mode ?? 0o100644) << 16) >>> 0, 38); entry.writeUInt32LE(offset, 42)
central.push(entry, name); offset += header.length + name.length + bytes.length
}
const directory = Buffer.concat(central), end = Buffer.alloc(22)
end.writeUInt32LE(0x06054b50); end.writeUInt16LE(files.length, 8); end.writeUInt16LE(files.length, 10)
end.writeUInt32LE(directory.length, 12); end.writeUInt32LE(offset, 16)
return Buffer.concat([...local, directory, end])
}
export const fixtureManifest = { id: "example.session", name: "Session example", version: "1.0.0", apiVersion: 1,
author: "Example", license: "MIT", repository: "https://github.com/example/session", permissions: ["session.context"] }
export const fixtureArchive = (html = "<p>Example</p>", changes = {}) => fixtureZip([
{ name: "manifest.json", data: JSON.stringify({ ...fixtureManifest, ...changes }) }, { name: "panel.html", data: html },
])
Loading
Loading