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
6 changes: 6 additions & 0 deletions .changeset/extract-reusable-skill-manager.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@spencer-kit/coder-studio": patch
---

Extract reusable Skill Manager domain, repositories, and orchestration services, then connect
Coder Studio through host adapters while preserving existing Skill command behavior.
6 changes: 6 additions & 0 deletions .changeset/fix-agent-runtime-port.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@spencer-kit/coder-studio": patch
---

Inject the actual OS-assigned backend port into agent sessions so Canvas and other automation
commands can connect in packaged Windows and WSL Desktop environments.
7 changes: 7 additions & 0 deletions .changeset/share-desktop-theme-preferences.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@spencer-kit/coder-studio": patch
"@coder-studio/desktop": patch
---

Share the selected theme across Local Windows and WSL Desktop windows through a locked Desktop
preference store with live cross-process updates and legacy browser settings fallback.
199 changes: 126 additions & 73 deletions .github/workflows/desktop-release.yml

Large diffs are not rendered by default.

115 changes: 88 additions & 27 deletions .github/workflows/product-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -746,9 +746,10 @@ jobs:
- accept-cli
- accept-runtime
- compatibility
if: success()
runs-on: ubuntu-latest
timeout-minutes: 20
if: success()
runs-on: ubuntu-latest
environment: desktop-production
timeout-minutes: 20
concurrency:
group: product-desktop-stable-promotion
cancel-in-progress: false
Expand All @@ -770,6 +771,8 @@ jobs:
PREVIOUS_POINTER_DIGEST: ${{ needs.prepare.outputs.previous_product_channel_sha256 }}
ACCEPTED_DESKTOP_CHANNEL_SHA256: ${{ needs.prepare.outputs.desktop_channel_sha256 }}
CANDIDATE_COMMIT: ${{ needs.publish-candidate.outputs.candidate_commit }}
CODER_STUDIO_RUNTIME_PUBLIC_KEY: ${{ secrets.DESKTOP_RUNTIME_PUBLIC_KEY }}
CODER_STUDIO_RUNTIME_SIGNING_PRIVATE_KEY: ${{ secrets.DESKTOP_RUNTIME_SIGNING_PRIVATE_KEY }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
Expand Down Expand Up @@ -821,17 +824,52 @@ jobs:
echo "Immutable Product digest mismatch: npm integrity" >&2
exit 1
fi

- name: Advance final npm dist-tag
run: npm dist-tag add "${PACKAGE_NAME}@${PRODUCT_VERSION}" "${FINAL_DIST_TAG}"

- name: Build next signed Product index
id: product-index
run: |
if [[ -z "${CODER_STUDIO_RUNTIME_PUBLIC_KEY}" || -z "${CODER_STUDIO_RUNTIME_SIGNING_PRIVATE_KEY}" ]]; then
echo "Production Runtime signing keys are required to advance the Product index" >&2
exit 1
fi
mkdir -p \
release/product-promotion/keys \
release/product-promotion/index-bootstrap \
release/product-promotion/next
printf '%s' "${CODER_STUDIO_RUNTIME_PUBLIC_KEY}" \
> release/product-promotion/keys/runtime-public.pem
printf '%s' "${CODER_STUDIO_RUNTIME_SIGNING_PRIVATE_KEY}" \
> release/product-promotion/keys/runtime-private.pem
index_args=()
if gh release download product-stable \
--pattern product-index.json \
--dir release/product-promotion/index-bootstrap 2>/dev/null; then
index_args+=(--existing-index release/product-promotion/index-bootstrap/product-index.json)
elif gh release download product-stable \
--pattern product-channel.json \
--dir release/product-promotion/index-bootstrap 2>/dev/null; then
index_args+=(--previous-channel release/product-promotion/index-bootstrap/product-channel.json)
fi
node --import tsx scripts/build-product-index.ts \
--candidate-channel release/product-promotion/candidate/product-channel.json \
"${index_args[@]}" \
--public-key release/product-promotion/keys/runtime-public.pem \
--private-key release/product-promotion/keys/runtime-private.pem \
--output release/product-promotion/next/product-index.json
index_digest=$(sha256sum release/product-promotion/next/product-index.json | cut -d ' ' -f 1)
echo "product_index_sha256=${index_digest}" >> "${GITHUB_OUTPUT}"

- name: Advance final npm dist-tag
run: npm dist-tag add "${PACKAGE_NAME}@${PRODUCT_VERSION}" "${FINAL_DIST_TAG}"

- name: Promote Product versioned release
run: gh release edit "${CANDIDATE_TAG}" --prerelease=false --latest=false

- name: Advance signed Product stable pointer
run: |
pointer=release/product-promotion/candidate/product-channel.json
final_digest=$(sha256sum "${pointer}" | cut -d ' ' -f 1)
- name: Advance signed Product stable pointer
run: |
pointer=release/product-promotion/candidate/product-channel.json
index_pointer=release/product-promotion/next/product-index.json
final_digest=$(sha256sum "${pointer}" | cut -d ' ' -f 1)
if [[ "${final_digest}" != "${PRODUCT_CHANNEL_SHA256}" ]]; then
echo "Immutable Product digest mismatch: product-channel.json" >&2
exit 1
Expand All @@ -844,16 +882,16 @@ jobs:
if [[ "${current_digest}" != "${PREVIOUS_POINTER_DIGEST}" && "${current_digest}" != "${final_digest}" ]]; then
echo "Product stable pointer changed after acceptance" >&2
exit 1
fi
if gh release view product-stable >/dev/null 2>&1; then
gh release upload product-stable "${pointer}" --clobber
else
gh release create product-stable "${pointer}" \
--target "${GITHUB_SHA}" \
--title "Coder Studio Product stable channel" \
--notes "Signed mutable Product channel pointer." \
--latest=false
fi
fi
if gh release view product-stable >/dev/null 2>&1; then
gh release upload product-stable "${index_pointer}" "${pointer}" --clobber
else
gh release create product-stable "${index_pointer}" "${pointer}" \
--target "${GITHUB_SHA}" \
--title "Coder Studio Product stable channel" \
--notes "Signed mutable Product channel and accepted release index." \
--latest=false
fi

- name: Verify promoted Product
run: |
Expand All @@ -871,13 +909,36 @@ jobs:
sleep 10
done
mkdir -p release/product-promotion/final
gh release download product-stable --pattern product-channel.json --dir release/product-promotion/final
final_digest=$(sha256sum release/product-promotion/final/product-channel.json | cut -d ' ' -f 1)
final_version=$(node -p "require('./release/product-promotion/final/product-channel.json').version")
if [[ "${final_digest}" != "${PRODUCT_CHANNEL_SHA256}" || "${final_version}" != "${PRODUCT_VERSION}" ]]; then
echo "Promoted Product stable pointer verification failed" >&2
exit 1
fi
gh release download product-stable --pattern 'product-*.json' --dir release/product-promotion/final
final_digest=$(sha256sum release/product-promotion/final/product-channel.json | cut -d ' ' -f 1)
final_index_digest=$(sha256sum release/product-promotion/final/product-index.json | cut -d ' ' -f 1)
final_version=$(node -p "require('./release/product-promotion/final/product-channel.json').version")
if [[ "${final_digest}" != "${PRODUCT_CHANNEL_SHA256}" || \
"${final_index_digest}" != "${{ steps.product-index.outputs.product_index_sha256 }}" || \
"${final_version}" != "${PRODUCT_VERSION}" ]]; then
echo "Promoted Product stable pointer verification failed" >&2
exit 1
fi
node --import tsx --input-type=module <<'NODE'
import { readFileSync } from "node:fs";
import { parseProductChannel } from "./packages/desktop/src/product-channel.ts";
import { parseProductIndex } from "./packages/desktop/src/product-index.ts";
const root = "release/product-promotion/final";
const key = readFileSync("release/product-promotion/keys/runtime-public.pem", "utf8");
const channel = parseProductChannel(
JSON.parse(readFileSync(`${root}/product-channel.json`, "utf8")),
key,
"https://github.com/${{ github.repository }}/releases/download/product-stable/product-channel.json"
);
const index = parseProductIndex(
JSON.parse(readFileSync(`${root}/product-index.json`, "utf8")),
key,
"https://github.com/${{ github.repository }}/releases/download/product-stable/product-index.json"
);
if (channel.version !== process.env.PRODUCT_VERSION || index.latestVersion !== process.env.PRODUCT_VERSION) {
throw new Error("Promoted Product index does not contain the accepted latest Product");
}
NODE

- name: Remove temporary npm dist-tag
run: |
Expand Down
86 changes: 76 additions & 10 deletions docs/promotion/product-desktop-release-runbook.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,60 @@
Coder Studio has two independently owned production release boundaries:

- A **Product release** publishes the CLI together with the Windows and WSL Product Runtime at one
Product version. Its signed pointer is `product-stable/product-channel.json`.
Product version. Product owns both `product-stable/product-channel.json` (the latest-only legacy
pointer) and `product-stable/product-index.json` (the accepted Runtime history).
- A **Desktop release** publishes the Desktop Shell, Windows Engine, and WSL Engine at one Desktop
version. It packages the already accepted Factory Runtime identified by the Desktop channel, but
does not rebuild or republish Product Runtime. Its signed pointer is
`desktop-stable/desktop-channel.json`.

The fixed pointer releases contain only signed channel documents. Channels name immutable versioned
release tags and safe asset names; clients never infer component state from GitHub's repository-wide
latest selection.
The fixed pointer releases contain only signed channel/index documents. They name immutable
versioned release tags and safe asset names; clients never infer component state by enumerating
GitHub Releases or from GitHub's repository-wide latest selection.

`product-index.json` is the source of truth for new Shell builds and installed Shell updates. Every
successful Product promotion adds one accepted release and signs the complete index with the Runtime
Ed25519 key. Failed candidates never enter it, and Desktop publication has read-only access. A
consumer selects the highest semantic version compatible with its effective Shell version, Engine,
Node, Runtime Host API, API Protocol, and Data Schema. If the newest entry is incompatible, the
consumer continues with the next-highest accepted entry; if none is compatible, it fails closed.

The schema is intentionally an index of immutable identities, not a second artifact store:

```json
{
"schemaVersion": 1,
"channel": "product-index",
"generatedAt": "2026-08-25T08:00:00.000Z",
"latestVersion": "0.5.13",
"releases": [
{
"version": "0.5.13",
"releaseTag": "v0.5.13",
"publishedAt": "2026-08-25T07:30:00.000Z",
"minShellVersion": "0.1.5",
"requirements": {
"engineVersion": "2",
"nodeVersion": "24.19.0",
"runtimeHostApiVersion": 1,
"apiProtocolVersion": 1,
"dataSchemaVersion": 1
},
"runtimes": {
"win32-x64": {
"manifest": "coder-studio-runtime-win32-x64.manifest.json",
"manifestSha256": "<64 lowercase hex characters>"
},
"linux-x64": {
"manifest": "coder-studio-server-runtime-linux-x64.manifest.json",
"manifestSha256": "<64 lowercase hex characters>"
}
}
}
],
"signature": { "algorithm": "ed25519", "value": "<base64 signature>" }
}
```

## Normal Product release

Expand All @@ -24,13 +69,25 @@ After every acceptance job succeeds, promotion is automatic. It advances the req
(normally `latest`), converts the versioned Product prerelease to a normal non-latest release,
advances `product-stable`, verifies the result, removes the temporary dist-tag, and writes
`promotion.json`. There is no run-ID handoff, environment approval, or separate manual promotion.
The promotion job is the only writer of `product-index.json`: it verifies the accepted candidate and
existing index, bootstraps from the previous signed `product-channel.json` when no index exists,
merges idempotently, signs, uploads the index before the legacy pointer, and verifies the remote
bytes and signatures.

## Normal Desktop release

Merging a release-ready Desktop version change in `packages/desktop/package.json` to `main` starts
`desktop-release.yml`. It resolves and verifies `product-stable`, embeds those exact Factory Runtime
bytes and their provenance, and builds only the Shell, installer, Windows Engine, and WSL Engine. The
candidate uses the final `desktop-v<version>` tag.
`desktop-release.yml`. It builds the target `build-info.json`, requires the signed Product index, and
selects the highest compatible accepted Runtime. It downloads only the exact immutable tag,
manifests, and packages named by that entry, embeds those Factory Runtime bytes and their provenance,
and builds only the Shell, installer, Windows Engine, and WSL Engine. Missing/invalid indexes and an
empty compatible set are hard failures; normal publication never falls back to release enumeration
or the legacy bundle-recovery script. The candidate uses the final `desktop-v<version>` tag.

The selected index version must equal `factory-product.json`, the packaged Factory Runtime manifest,
and the Desktop channel's `factoryProduct.version`. Installed production Shells use the same index
selector. Explicit acceptance launches continue to consume their tag-pinned candidate
`product-channel.json`, because a candidate is not accepted and indexed until promotion.

Installed/fresh, offline Factory/fallback, and current/previous Product compatibility acceptance all
consume the immutable candidate. After they pass, promotion automatically converts the versioned
Expand All @@ -47,6 +104,11 @@ it, and compares every accepted digest before continuing incomplete acceptance o
The same rule applies to fixed pointers and `promotion.json`: an existing identical byte sequence is
idempotent; an existing different byte sequence is a hard failure.

A resumed Desktop candidate is also compared with the current highest compatible Product index
entry. If a newer compatible Runtime was accepted after that candidate was built, the old candidate
cannot be published: increment the Desktop patch version and build a new candidate with the current
Factory Runtime.

An **immutable-byte mismatch** is not repaired with `--clobber`. Retain the failed release for
diagnosis, increment to a higher patch version, and publish a new immutable candidate. Never move a
stable pointer backward as a downgrade mechanism.
Expand All @@ -57,7 +119,7 @@ Product and Desktop workflows use independent serialized locks (`product-product
`desktop-production`) for candidate construction and acceptance. Their promotion jobs additionally
share `product-desktop-stable-promotion`, so only one accepted tuple can move a stable pointer at a
time. After acquiring that lock, Product rechecks the accepted `desktop-stable` digest and Desktop
rechecks the accepted `product-stable` digest before changing any external state. If the counterpart
rechecks the accepted Product index digest before changing any external state. If the counterpart
changed after compatibility acceptance, promotion stops and the same `candidate_tag` must rerun the
complete compatibility graph against the new stable tuple.

Expand All @@ -78,8 +140,10 @@ Normal publication may update a pointer only after immutable candidate acceptanc
migration, `desktop-bridge-release.yml` creates a missing pointer or verifies that an existing pointer
has exactly the expected digest. A different existing digest stops the bridge.

The Product pointer is copied byte-for-byte from the bridge candidate's accepted
`product-channel.json`. The Desktop pointer is copied byte-for-byte from
The Product legacy pointer is copied byte-for-byte from the bridge candidate's accepted
`product-channel.json`. The next accepted Product promotion bootstraps `product-index.json` from that
pointer and its new candidate; run that Product promotion before the first index-based Desktop
publication. The Desktop pointer is copied byte-for-byte from
`desktop-channel-modern.json` and uploaded to `desktop-stable` under the canonical asset name
`desktop-channel.json`. The bridge retains its legacy `desktop-channel.json` under the original name
for already installed clients.
Expand Down Expand Up @@ -123,6 +187,8 @@ After the hosted workflow succeeds, verify:

- repository-wide latest still names the bridge tag;
- `product-stable/product-channel.json` has the accepted digest and names the immutable Product tag;
- `product-stable/product-index.json` has a valid signature and contains every subsequently accepted
Product release exactly once;
- `desktop-stable/desktop-channel.json` has the accepted modern-channel digest and names the bridge
tag;
- both native and WSL independent-feed acceptance reports were uploaded;
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@
"ci:test:scripts": "pnpm exec vitest run --config scripts/vitest.config.ts --environment node",
"ci:test:workspace": "pnpm -r --filter './packages/**' --filter '!@coder-studio/web' --if-present run test && pnpm --filter @coder-studio/web test",
"ci:test": "pnpm ci:test:scripts && pnpm ci:test:workspace",
"ci:typecheck:strict": "pnpm --filter @spencer-kit/coder-studio exec tsc -p tsconfig.json --noEmit && pnpm --filter @coder-studio/utils exec tsc -p tsconfig.json --noEmit && pnpm --filter @coder-studio/core exec tsc -p tsconfig.json --noEmit && pnpm --filter @coder-studio/providers exec tsc -p tsconfig.json --noEmit && pnpm --filter @coder-studio/server exec tsc -p tsconfig.json --noEmit && pnpm --filter @coder-studio/desktop typecheck",
"ci:typecheck:strict": "pnpm --filter @spencer-kit/coder-studio exec tsc -p tsconfig.json --noEmit && pnpm --filter @coder-studio/utils exec tsc -p tsconfig.json --noEmit && pnpm --filter @coder-studio/skill-manager exec tsc -p tsconfig.json --noEmit && pnpm --filter @coder-studio/core exec tsc -p tsconfig.json --noEmit && pnpm --filter @coder-studio/providers exec tsc -p tsconfig.json --noEmit && pnpm --filter @coder-studio/server exec tsc -p tsconfig.json --noEmit && pnpm --filter @coder-studio/desktop typecheck",
"ci:typecheck:web": "tsx scripts/check-typecheck-baseline.ts",
"ci:typecheck": "pnpm ci:typecheck:strict && pnpm ci:typecheck:web",
"ci:build": "pnpm --filter @coder-studio/server build && pnpm build",
Expand Down
3 changes: 3 additions & 0 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@coder-studio/skill-manager": "workspace:*"
},
"peerDependencies": {
"zod": "^4.4.2"
},
Expand Down
27 changes: 27 additions & 0 deletions packages/core/src/domain/desktop-preferences.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
export const DESKTOP_PREFERENCES_SCHEMA_VERSION = 1 as const;

export interface DesktopPreferencesSnapshot {
schemaVersion: typeof DESKTOP_PREFERENCES_SCHEMA_VERSION;
revision: number;
updatedAt: string | null;
appearance: {
themeId: string | null;
};
}

export interface DesktopPreferencesPatch {
appearance?: {
themeId?: string;
};
}

export function createDefaultDesktopPreferences(): DesktopPreferencesSnapshot {
return {
schemaVersion: DESKTOP_PREFERENCES_SCHEMA_VERSION,
revision: 0,
updatedAt: null,
appearance: {
themeId: null,
},
};
}
Loading
Loading