Skip to content
Draft
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
2 changes: 1 addition & 1 deletion apps/desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@
"dependencies": {
"@better-auth/api-key": "1.7.0-rc.2",
"@better-auth/electron": "1.7.0-rc.2",
"@linkcode/cloud": "file:../../packages/vendor/linkcode-cloud-0.1.0.tgz",
"@linkcode/cloud": "file:../../packages/vendor/linkcode-cloud-0.1.1.tgz",
"@linkcode/common": "workspace:*",
"@linkcode/daemon": "workspace:*",
"@linkcode/ipc": "workspace:*",
Expand Down
15 changes: 15 additions & 0 deletions apps/desktop/src/main/__tests__/cloud-hosted-billing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,9 @@ vi.mock('../cloud-auth/storage', () => ({

describe('desktop hosted billing handoff', () => {
beforeEach(() => {
vi.resetModules();
vi.clearAllMocks();
delete process.env.LINKCODE_PROFILE;
mocks.handlers.clear();
mocks.setAsDefaultProtocolClient.mockReturnValue(true);
mocks.openExternal.mockResolvedValue(undefined);
Expand All @@ -76,6 +78,19 @@ describe('desktop hosted billing handoff', () => {
);
});

it('isolates the native return target by profile', async () => {
process.env.LINKCODE_PROFILE = 'code-603';
const { setupCloudAuth } = await import('../cloud-auth/client');
const { CLOUD_OPEN_HOSTED_BILLING_CHANNEL } = await import('../../shared/cloud');
setupCloudAuth();

await mocks.handlers.get(CLOUD_OPEN_HOSTED_BILLING_CHANNEL)?.();

expect(mocks.openExternal).toHaveBeenCalledWith(
'https://console.linkcode.ai/billing?returnTarget=linkcode-dev-code-603%3A%2F%2Fbilling%2Freturn',
);
});

it('mints a Gateway key in the signed-in session organization', async () => {
const { setupCloudAuth } = await import('../cloud-auth/client');
const { CLOUD_CREATE_GATEWAY_KEY_CHANNEL } = await import('../../shared/cloud');
Expand Down
7 changes: 5 additions & 2 deletions apps/desktop/src/main/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,12 +76,15 @@ export const STORAGE_DIR_NAME =

/**
* OAuth deep-link protocol (see cloud-auth/client.ts): brand-owned when a brand identity is
* embedded, split per channel so a development build never fights the installed release over
* embedded, split per channel and profile so concurrently running identities never fight over
* the OS-global scheme.
*/
export const CLOUD_AUTH_SCHEME =
const CLOUD_AUTH_SCHEME_BASE =
BRAND_BASE?.authScheme ?? (CHANNEL === 'development' ? 'linkcode-dev' : 'linkcode');

export const CLOUD_AUTH_SCHEME =

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The - separator is inside the profile charset, so it reintroduces the collision this change exists to remove: release channel + --profile=dev yields linkcode-dev, byte-identical to the development channel's default scheme (--profile=dev-alpha likewise aliases dev-channel-alpha). Both names pass PROFILE_NAME_PATTERN. Every other identity axis picks a separator the profile charset forbids — . for the state dir and APP_ID, (name) for APP_NAME and the keyring service — for exactly this reason.

Technical details
# Profile separator `-` aliases the channel suffix

## Affected sites
- `apps/desktop/src/main/constants.ts:85-86``` `${CLOUD_AUTH_SCHEME_BASE}-${PROFILE}` ``; `linkcode` + `dev` == `linkcode-dev`.
- `packages/foundation/schema/src/daemon-runtime.ts:42``PROFILE_NAME_PATTERN = /^[a-z0-9][a-z0-9-]{0,31}$/` admits `dev` and `dev-alpha`.
- `packages/foundation/schema/src/product.ts:52-57` — the existing comment stating that the state dir's dot separator is chosen precisely so `--profile=development` cannot reach the development channel's directory.
- Vendored `@linkcode/cloud` 0.1.1 — `/^linkcode(?:-dev)?(?:-[a-z0-9][a-z0-9-]{0,31})?:$/` is ambiguous the same way, so it cannot disambiguate server-side either.

## Required outcome
- A channel-plus-profile scheme must be unambiguous: no (channel, profile) pair may produce the same scheme string as a different pair.

## Suggested approach (optional)
- RFC 3986 allows `+`, `-`, and `.` in a scheme after the first character, so a `.` separator (`linkcode-dev.alpha`) mirrors the state-dir discipline and is unambiguous because profile names forbid dots. This changes the wire-visible scheme, so it needs a matching `@linkcode/cloud` release and a linkcodehq-side update — worth confirming before the vendored 0.1.1 regex is treated as settled.

PROFILE === undefined ? CLOUD_AUTH_SCHEME_BASE : `${CLOUD_AUTH_SCHEME_BASE}-${PROFILE}`;
Comment on lines +85 to +86

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Packaged schemes omit profiles

When a packaged release, development-shell, or branded app runs with a profile, CLOUD_AUTH_SCHEME gains a profile suffix while the package metadata still registers only the unsuffixed scheme, causing OAuth sign-in and hosted-billing callbacks to use a native URL scheme the operating system cannot reliably route back to that app.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On macOS this scheme can never be registered. Electron's docs are explicit: "you can only register protocols that have been added to your app's info.plist, which cannot be modified at runtime" — and the protocols: blocks in electron-builder.release.yml / electron-builder.devshell.yml are build-time and profile-blind. So a packaged macOS build launched with a profile emits linkcode-<profile>:// on both the sign-in ?scheme= and the billing returnTarget, Launch Services has no handler, and the callback dead-ends silently — where before it at least reached the base-scheme app. @better-auth/electron's registerProtocolScheme only console.errors the failed registration, which is invisible in a packaged app.

Technical details
# Profile-suffixed deep-link scheme is unregistrable on macOS

## Affected sites
- `apps/desktop/src/main/constants.ts:85-86` — derives `<base>-<profile>` at launch time; nothing declares it to the OS.
- `apps/desktop/electron-builder.release.yml` / `electron-builder.devshell.yml``protocols:` lists only `linkcode` / `linkcode-dev`; these are baked at package time and cannot know the runtime `--profile`.
- `apps/desktop/src/main/cloud-auth/client.ts:59-62` (`claimDeepLink`) and `node_modules/@better-auth/electron/dist/client.mjs` (`registerProtocolScheme`) — both call `app.setAsDefaultProtocolClient`; the latter only `console.error`s on a `false` return.
- `docs/DEVELOPMENT.md:325` — the added claim "OAuth callbacks return to the matching app" does not hold on macOS.

## Required outcome
- A packaged macOS build launched with `--profile=<name>` must either receive its OAuth/billing callback, or fail loudly and predictably rather than dead-ending.
- The docs must not claim per-profile callback routing on a platform where it cannot work.

## Suggested approach (optional)
- Confirm the behavior on a packaged macOS build (`pnpm run package:devshell`, launch with a profile, watch the `setAsDefaultProtocolClient` return value) before choosing a fix — Windows registers at runtime and is unaffected, so this is macOS-specific.
- Options, roughly in increasing cost: keep the base scheme on `darwin` and disambiguate the profile inside the callback URL; declare a small reserved set of profile schemes in the `protocols:` blocks; or scope the suffix to the platforms where runtime registration works and document the macOS limitation.

## Open questions for the human
- Is a packaged macOS build with a profile a supported configuration, or is the profile flow dev-shell/Windows only?


/** The channel's workspace directory (`~/LinkCode`, `~/LinkCode Development`) — shared across
* that channel's profiles on purpose, but never across channels (CODE-460). Must agree with the
* daemon's `chatWorkspaceRoot()`, which derives the same name from its own resolved channel. */
Expand Down
2 changes: 1 addition & 1 deletion apps/webview/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
},
"dependencies": {
"@hookform/resolvers": "^5.5.7",
"@linkcode/cloud": "file:../../packages/vendor/linkcode-cloud-0.1.0.tgz",
"@linkcode/cloud": "file:../../packages/vendor/linkcode-cloud-0.1.1.tgz",
"@linkcode/common": "workspace:*",
"@linkcode/schema": "workspace:*",
"@linkcode/sdk": "workspace:*",
Expand Down
4 changes: 2 additions & 2 deletions docs/DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -322,7 +322,7 @@ git apply --3way "$(ls -t .devenv/state/prek/patches/*.patch | head -1)"
The identity is two orthogonal axes (`apps/desktop/src/main/constants.ts`), and the desktop's app name, `userData` dir, single-instance lock, and OS keychain (safeStorage) all derive from them; `src/main/identity.ts` applies the identity as main's **first import**, and boot logs a `userData: <path>` line as self-evidence. Since CODE-460 the **daemon's** state follows the same two axes, so a local build and an installed release share nothing at all.

- **channel** — `CHANNEL === 'development'` for any build that is not the released app: `MODE !== 'production' || !app.isPackaged` (a production bundle run by the dev Electron binary is still a dev shell). `APP_NAME` is `'LinkCode Development'` for dev, `'LinkCode'` for release. Skipping any isolation axis clobbers release settings, steals its instance lock (the second instance exits 0 silently), or writes a safeStorage key under the dev binary's code signature — after which the release app prompts for the keychain password on first launch (macOS keychain ACLs pin the creator cdhash).
- **profile** — an optional isolated universe *within* a channel: `--profile=<name>` (or `LINKCODE_PROFILE`; `[a-z0-9-]`, ≤32 chars, invalid aborts boot). It suffixes the app name (`LinkCode Development (alpha)`) — forking the same four axes again — and is injected as `LINKCODE_PROFILE` into the supervised daemon, which forks its state dir and cloud device identity with it. Profiles run side by side: daemons hunt past each other's ports, and each desktop follows its own `runtime.json`. The devenv `daemon`/`desktop`/`app` scripts pass **no** profile — the development channel is already its own universe; pass one yourself only to fork a second universe within that channel.
- **profile** — an optional isolated universe *within* a channel: `--profile=<name>` (or `LINKCODE_PROFILE`; `[a-z0-9-]`, ≤32 chars, invalid aborts boot). It suffixes the app name (`LinkCode Development (alpha)`) and OAuth scheme (`linkcode-dev-alpha://`) — forking the same identity surfaces again — and is injected as `LINKCODE_PROFILE` into the supervised daemon, which forks its state dir and cloud device identity with it. Profiles run side by side: daemons hunt past each other's ports, each desktop follows its own `runtime.json`, and OAuth callbacks return to the matching app. The devenv `daemon`/`desktop`/`app` scripts pass **no** profile — the development channel is already its own universe; pass one yourself only to fork a second universe within that channel.

The daemon is a separate process and cannot see `app.isPackaged`, so it resolves its own channel (`apps/daemon/src/paths.ts`): the desktop supervisor's injected `LINKCODE_CHANNEL` wins, else the build-time stamp tsup bakes in (`process.env.LINKCODE_BUILD_CHANNEL` → `release`), else `development`. Running the TS source is therefore a development daemon with nothing to remember, while a packaged one is release — and the devshell pack, which ships a `release`-stamped bundle inside a development shell, is corrected by the injection. Resolution is per call, never cached at module load: `instrument.ts` derives a state path in its module body and `--import` runs it before `index.ts`.

Expand Down Expand Up @@ -355,7 +355,7 @@ The daemon holds no credential in those files: `secrets.json` is AES-256-GCM cip

A profile appends `-<name>` to the **state** directory only (`~/.linkcode.development-alpha`); workspaces and the asset store fork by channel alone. The development suffix is dot-separated on purpose: profile names forbid dots, so `--profile=development` can never reach the development channel's directory.

Two things stay shared across channels by design: the agent CLIs' own homes (`~/.claude`, `~/.codex`separating them would force a second agent login and cut you off from the CLI you use in a terminal), and the `linkcode://` scheme's OS-global nature, which is why the dev shell claims `linkcode-dev://` instead (CODE-182).
The agent CLIs' own homes (`~/.claude`, `~/.codex`) stay shared across channels by design; separating them would force a second agent login and cut you off from the CLI you use in a terminal. OAuth schemes are OS-global, so the dev shell claims `linkcode-dev://` and profiles append their name.

Ports fork with the state: **release hunts 19523–19532, development 19533–19542**. The ranges are disjoint deliberately. The identity's `channel` field is the precise signal, but it cannot defend against a daemon shipped *before* that field existed — an old peer parses the newer identity through a schema without the key, zod strips it, and its profile-only comparison reads two default profiles as equal. Such a release would exit 3 against a development daemon sitting on 19523 and its supervisor would stand down, leaving the release shell to fall back to that same port and dial the wrong daemon. Never letting the channels reach one another's ports is the only fix that reaches binaries already in the wild.

Expand Down
2 changes: 1 addition & 1 deletion docs/ENVIRONMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ Read by the daemon, desktop, webview, or mobile at run time.
| --- | --- | --- |
| `LINKCODE_CHANNEL` | `apps/daemon/src/paths.ts` | Picks the daemon's on-disk universe: `release` → `~/.linkcode` + `~/LinkCode` + `…/LinkCode/assets`; `development` → the `LinkCode Development` / `.linkcode.development` set (CODE-460). Outranks the build-time stamp, which is why the desktop supervisor always injects its own `CHANNEL` — the devshell pack's bundled daemon is stamped `release`. Any other value aborts boot. |
| `LINKCODE_BUILD_CHANNEL` | `apps/daemon/tsup.config.ts` (`define`) | **Build-time stamp, not a runtime knob.** tsup replaces the literal with `release`, so a built daemon defaults to release and the TS source defaults to development. Setting it by hand in a shell works but is not the supported override — use `LINKCODE_CHANNEL`. |
| `LINKCODE_PROFILE` | `apps/daemon/src/config.ts` | Isolated state universe *within a channel*: forks the state dir to the `-<name>` sibling (`~/.linkcode.development-alpha`), plus DB, `runtime.json`, and HQ device identity. `[a-z0-9-]`, ≤32 chars; invalid aborts boot. Workspaces and the asset store do not fork by profile. Desktop reads it too, where `--profile=<name>` outranks it, and re-injects the resolved value into the supervised daemon. Unset = the channel's default universe. |
| `LINKCODE_PROFILE` | `apps/daemon/src/config.ts` | Isolated state universe *within a channel*: forks the state dir to the `-<name>` sibling (`~/.linkcode.development-alpha`), plus DB, `runtime.json`, HQ device identity, and the desktop OAuth scheme (`linkcode-dev-alpha://`). `[a-z0-9-]`, ≤32 chars; invalid aborts boot. Workspaces and the asset store do not fork by profile. Desktop reads it too, where `--profile=<name>` outranks it, and re-injects the resolved value into the supervised daemon. Unset = the channel's default universe. |
| `LINKCODE_PORT` | `apps/daemon/src/config.ts` | Overrides every configured listener's port. Must parse as an integer in `1..65535`, otherwise the config value stands. |
| `LINKCODE_HOST` | `apps/daemon/src/config.ts` | Overrides every listener's bind host. |
| `LINKCODE_PTY_SIDECAR_PATH` | `apps/daemon/src/pty/sidecar.ts` | Absolute path to the `linkcode-pty` binary; always wins. Dev falls back to `target/release/linkcode-pty`; a bundled `dist/` daemon has no fallback and disables terminals. The packaged desktop supervisor sets it to `<resourcesPath>/sidecar/<arch>`. |
Expand Down
Binary file removed packages/vendor/linkcode-cloud-0.1.0.tgz
Binary file not shown.
Binary file added packages/vendor/linkcode-cloud-0.1.1.tgz
Binary file not shown.
16 changes: 8 additions & 8 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading