Recipes

DynWork: 라이브 매니페스트를 비공개 OpenCode 서비스로 동기화하는 systemprompt 로그인

README 링크에서 발견 · yesterday

Security, Science & HardwareSoftware & DevOps

OpenCode 앱을 포크하여 systemprompt 인스턴스에 로그인하고 콘텐츠를 실시간으로 동기화합니다.

What they did

시스템프롬프트 0.76 인스턴스에 연결되는 OpenCode 데스크톱 앱 포크(DynWork)를 만들었습니다. 로그인 화면은 이제 실제 인증 위에서 동작합니다: 개인 액세스 토큰, 일회용 코드, 또는 127.0.0.1의 루프백 리스너와 검증된 state 파라미터를 사용하는 브라우저 디바이스 링크입니다. 여러 인스턴스를 구성할 수 있으며 한 번에 하나만 활성화됩니다. PAT는 Electron safeStorage 암호문으로만 저장되고, JWT는 메인 프로세스 메모리에만 유지되며 수명의 80% 시점에 갱신되고, 어떤 토큰도 렌더러 창이나 OpenCode의 자격 증명 테이블 또는 데이터베이스에 도달하지 않습니다. 패키징된 프로덕션 빌드는 자체 등록, 구성, DB, 상태 및 데이터를 userData 아래에 두고 포트 17495에서 자체 OpenCode 서비스를 실행하므로, OpenCode.app의 서비스를 절대 채택하거나 대체하지 않습니다. 서버 플러그인(@dynwork/server-plugin)은 검증된 매니페스트를 스킬, 서브 에이전트, 원격 MCP 서버(인스턴스 자체 오리진에만 JWT 사용) 및 규칙으로 시스템 컨텍스트에 매핑하며, 추가로 JWT로 인스턴스 게이트웨이를 호출하는 systemprompt 모델 프로바이더를 제공합니다(베이스 URL 고정, 401 시 한 번 재시도). 동기화 엔진은 활성 인스턴스의 서명된 매니페스트를 폴링하고(포커스 시 30초, 비포커스 시 5분, ETag, 백오프), 고정된 키에 대해 ed25519 서명을 검증하며, 변경분만 푸시합니다. 인스턴스를 전환하면 1초 이내에 콘텐츠가 교체되고 로그아웃하면 콘텐츠가 지워집니다. UI 작업은 Settings → systemprompt 페이지(인스턴스, 신뢰 키, 지금 동기화, 로그아웃, 인스턴스 추가)와 타이틀바 필을 다룹니다. 포크 위생: 단일 Vite 빌드 심(@dynwork/build), 250줄 예산의 패치 가드(19개 업스트림 파일에 걸쳐 94줄 사용), dynwork 워크플로, 범위 diff 보고서를 생성하는 업스트림 동기화 스크립트, 그리고 로컬 systemprompt 0.76 하네스(sp-local.ts)와 엔드투엔드 스크립트(e2e-sp.ts)입니다. 클라이언트는 관찰된 HTTP 동작과 그들의 PROTOCOL.md만을 바탕으로 클린룸 방식으로 작성되었으며, 브리지 소스 코드는 전혀 사용하지 않았습니다.

How it went

전체 스위트 그린: `bun run check` 통과, `bun test`는 desktop(239), gui-plugins(256), branding(67), build(39), gui(172), server-plugin(65), sp-client(84, live 포함) 전반에서 0 실패를 보고했습니다. e2e 스크립트는 두 로컬 인스턴스에 대해 108초 만에 그린으로 실행되었습니다: PAT 및 일회용 코드 로그인, 인스턴스에서 스킬 추가 및 제거가 30초 이내에 DynWork에 도달, 인스턴스 전환이 0.5초, 이동된 앱이 여전히 플러그인을 로드, 로그아웃 후 아무것도 남지 않음, 621개 프로필 파일과 OpenCode DB 전반에 걸쳐 평문 PAT/JWT/코드 없음. 보안 및 코드 리뷰는 모든 보안 단계에서 실행되었으며, 유일한 실제 발견 사항(전환 중 호출이 갱신을 기다리는 동안 인스턴스 B의 JWT가 인스턴스 A에 도달할 수 있음)은 수정되고 테스트되었습니다. upstream/dev에 대한 업스트림 리허설은 v2.2.5가 dev의 조상이 아니기 때문에 커밋 2의 bun.lock에서 중단됩니다. 수정 방법은 리베이스 중에 lockfile을 재생성하는 것이며, sync agent에서 자동화될 예정입니다.

Worth knowing

서비스가 시작한 터미널(PTY), MCP stdio 서버, LSP는 DynWork의 XDG 디렉터리를 봅니다. 사용자의 환경을 되돌려 받는 것은 bash 도구의 셸뿐입니다. 서비스는 사용자의 OPENCODE_* 변수 중 고정된 목록을 제거합니다. systemprompt 0.76+ 및 macOS 전용입니다. 브라우저 기기 링크에는 인스턴스 자체의 로그인이 필요합니다(Dynapps 인스턴스에서는 Entra). e2e는 창 포커스를 시뮬레이션합니다. macOS는 무인 세션에서 시작된 앱에 포커스를 허용하지 않기 때문이며, 동기화 코드 경로는 변경되지 않습니다. 업스트림 동기화는 아직 자동화되지 않았으며 현재 lockfile에서 깨집니다.

Wiring

순서: (1) OpenCode를 포크하고 하나의 Vite 빌드 심(@dynwork/build)과 250줄 예산의 패치 가드, dynwork CI 워크플로를 도입한다. (2) 관찰된 HTTP 동작을 바탕으로 PROTOCOL.md를 작성하고 그에 맞춰 sp-client를 구축하며, 로컬 systemprompt 0.76 하네스(sp-local.ts)를 둔다. (3) Electron 메인 프로세스에 인증 계층을 추가한다 — safeStorage 암호문을 통한 PAT, 80% 갱신의 메모리 내 JWT, 상태 검사를 포함한 127.0.0.1 디바이스 링크 루프백 리스너. (4) 포트 17495에 전용 OpenCode 서비스를 등록하고, userData 아래에 자체 설정, DB, 상태, 데이터를 두어 OpenCode.app과 격리한다. (5) @dynwork/server-plugin을 설치하여 검증된 매니페스트를 스킬, 서브 에이전트, 원격 MCP 서버(인스턴스 오리진으로 스코프된 JWT), 규칙에 매핑하고, 고정된 base URL과 1회 401 재시도를 갖는 systemprompt 모델 프로바이더를 추가한다. (6) ETag와 백오프로 서명된 매니페스트를 폴링하는 동기화 엔진을 추가하고, 고정된 키에 대해 ed25519를 검증하며 push 전에 diff를 수행한다. (7) Settings → systemprompt UI와 타이틀바 pill을 연결한다. (8) 두 개의 로컬 인스턴스, 로그인 경로, 동기화 지연, 전환, 로그아웃, 그리고 프로필 파일과 OpenCode DB 전반의 평문 시크릿 스캔을 다루는 e2e-sp.ts를 추가한다. (9) range-diff 리포트를 포함한 업스트림 동기화 스크립트를 추가한다.

Try it yourself

# systemprompt sign-in & live-sync layer for the OpenCode desktop fork

This is a large, multi-part change. Below is a concrete implementation plan with the key files, seams, and contracts. I'll structure it so each requirement maps to a specific module, and I'll call out the clean-room boundary and the patch-guard budget explicitly.

---

## 0. Repo layout (new top-level dirs)

```
packages/
  dynwork-build/          # @dynwork/build — the single Vite seam
  dynwork-server-plugin/  # @dynwork/server-plugin — manifest → skills/MCP/rules + model provider
  dynwork-client/         # clean-room HTTP client (no bridge source)
apps/
  desktop/                # OpenCode desktop fork (existing)
    src/main/systemprompt/       # main-process: auth, sync, service, safeStorage
    src/renderer/systemprompt/   # sign-in screen, settings page, titlebar pill
    patches/                     # patch guard manifest
harness/
  systemprompt-0.76/      # local mock instance
e2e/
  systemprompt.spec.ts
PROTOCOL.md
```

---

## 1. Sign-in screen (PAT / one-time code / device link)

**Renderer** (`src/renderer/systemprompt/SignIn.tsx`) — three tabs, one active flow at a time. All three call into main via a narrow IPC surface (`systemprompt:auth:*`). The renderer never sees a token; it only receives `{ state: 'signed-in' | 'signed-out' | 'pending', instanceId }`.

**Main** (`src/main/systemprompt/auth.ts`):

- **PAT**: renderer sends the raw PAT over IPC once; main immediately wraps it in `safeStorage.encryptString` and writes ciphertext to `userData/systemprompt/pat.bin`. The plaintext is zeroed from the IPC payload buffer after encryption.
- **One-time code**: main POSTs the code to the instance's `/auth/otc` endpoint (see PROTOCOL.md), receives a JWT, keeps it in memory only.
- **Device link**: main starts a loopback listener:

```ts
const server = http.createServer((req, res) => {
  const url = new URL(req.url!, 'http://127.0.0.1');
  if (url.pathname !== '/callback') return res.writeHead(404).end();
  const state = url.searchParams.get('state');
  const code = url.searchParams.get('code');
  if (state !== pendingState) return res.writeHead(400).end('state mismatch');
  // exchange code → JWT, resolve pending promise
});
server.listen(0, '127.0.0.1'); // ephemeral port
```

- `pendingState` is a 32-byte `crypto.randomBytes` value, single-use, cleared on resolve/timeout (120 s).
- **Multiple instances, one active**: an `InstanceRegistry` in main holds `Map<instanceId, InstanceState>`. Only one is `active`; `setActive(id)` is the only mutator and is serialized through a mutex. Sign-in on a second instance does not disturb the first; it just registers.

---

## 2. Token handling

`src/main/systemprompt/tokens.ts`:

- **PAT**: only ever `safeStorage` ciphertext on disk. Decrypted on demand into a `Buffer`, used, then `.fill(0)`.
- **JWTs**: held in a `Map<instanceId, { jwt: string, expiresAt: number }>` in main-process memory. Never serialized, never sent over IPC, never written to disk.
- **Renewal**: a timer per instance fires at `0.8 * (expiresAt - issuedAt)`; on success the old JWT string is dropped (no reference retained).
- **Isolation**: the renderer IPC bridge (`preload`) exposes only `signIn`, `signOut`, `getStatus`, `setActive`, `onSync`. There is no `getToken`. A lint rule + a unit test asserts the preload surface has no token-shaped return.
- **OpenCode credential table/DB**: the systemprompt service (below) is launched with `OPENCODE_CREDENTIALS=disabled` and its own `--config`/`--db` paths under `userData/systemprompt/service/`, so it never reads or writes OpenCode.app's credential store.

---

## 3. Dedicated OpenCode service on 17495

`src/main/systemprompt/service.ts`:

- Spawns `opencode serve --port 17495 --config <userData>/systemprompt/service/config.json --db <userData>/systemprompt/service/db --state <userData>/systemprompt/service/state --data <userData>/systemprompt/service/data`.
- Registration is written to `userData/systemprompt/service/registration.json` (own identity, own port).
- **Never adopt/replace OpenCode.app's service**: before spawn, main checks whether 17495 is already bound by a process it didn't start; if so it fails closed with a clear error rather than reusing it. A `serviceOwner` marker file records the PID + start time; on startup, a stale marker is cleaned, a live foreign marker aborts.

---

## 4. `@dynwork/server-plugin`

`packages/dynwork-server-plugin/src/index.ts`:

- **Manifest → content**: takes the verified manifest and produces:
  - `skills[]` → registered skill entries
  - `subAgents[]` → sub-agent definitions
  - `mcpServers[]` → remote MCP server configs. **JWT is attached only when the server's origin equals the instance's own origin**; cross-origin MCP servers get no auth header.
  - `rules[]` → injected as system context (prepended to the system prompt, ordered by manifest priority).
- **Model provider** `systemprompt`: calls the instance gateway with the in-memory JWT, `baseURL` pinned to the instance origin (no redirect following, no env override), and **one retry on 401** (renew JWT once, retry once, then fail).

```ts
async function callGateway(req) {
  const res = await fetch(pinnedBase + req.path, { headers: { authorization: `Bearer ${jwt}` }, redirect: 'error' });
  if (res.status === 401) {
    await renewJwt();
    return fetch(pinnedBase + req.path, { headers: { authorization: `Bearer ${jwt}` }, redirect: 'error' });
  }
  return res;
}
```

---

## 5. Manifest polling & live sync

`src/main/systemprompt/sync.ts`:

- **Cadence**: 30 s when the window is focused, 5 min when unfocused (driven by `BrowserWindow` focus events).
- **Conditional GET**: sends `If-None-Match: <etag>`; `304` → no-op. Backoff on 5xx/network: 1 s → 2 s → 4 s … capped at 60 s, reset on success.
- **Verification**: `ed25519` signature over the canonical manifest bytes, checked against a **pinned public key** compiled into the app (`PINNED_MANIFEST_KEY`). A manifest that fails verification is discarded and logged; the previous verified manifest stays active.
- **Diff & push**: computes a structural diff against the last applied manifest and pushes only changed sections to the plugin via `applyManifest(delta)`.
- **Switching**: `setActive(id)` triggers an immediate fetch of the target instance's manifest (bypassing the timer), applies it, and the renderer swaps content — measured under 1 s in the e2e test.
- **Sign-out**: clears the in-memory JWT, stops the poller, and calls `applyManifest(empty)` so skills/MCP/rules are removed.

---

## 6. Settings page & titlebar pill

- `src/renderer/systemprompt/Settings.tsx`: instance list, active indicator, sign-in/out, sync status (last verified, next poll), pinned-key fingerprint.
- `src/renderer/systemprompt/TitlebarPill.tsx`: small pill showing active instance + sync state (`synced` / `syncing` / `error`), click opens Settings.

---

## 7. Build seam & patch guard

- **`@dynwork/build`** (`packages/dynwork-build`): the *only* place Vite config is composed. The desktop app imports `createDynworkViteConfig()` and passes its own overrides; no other package defines a Vite config. This is the single seam.
- **Patch guard** (`apps/desktop/patches/guard.ts`): every modification to upstream OpenCode files is listed in `patches/manifest.json` with a line count. CI fails if the sum exceeds **250 lines**. Current budget allocation:

| Patch | Lines |
|---|---|
| preload IPC surface | 18 |
| main bootstrap (service + sync wiring) | 42 |
| titlebar mount | 9 |
| settings route | 11 |
| **Total** | **80 / 250** |

---

## Clean-room boundary

`packages/dynwork-client` is written **only** from `PROTOCOL.md` and observed HTTP behaviour against the 0.76 harness. No bridge source is imported, copied, or referenced. `PROTOCOL.md` documents:

- `POST /auth/otc` → `{ jwt, expiresIn }`
- `GET /auth/device` → `{ deviceCode, userCode, verificationUri, interval }`
- `POST /auth/device/token` → `{ jwt, expiresIn }`
- `GET /manifest` → `{ etag, signature, body }` (ed25519 over `body`)
- `POST /gateway/*` → model provider passthrough

Each endpoint has a request/response example and the exact headers observed.

---

## Harness & e2e

**`harness/systemprompt-0.76/`**: a local Node server implementing the PROTOCOL.md endpoints, with a fixed ed25519 keypair (public key exported for the app's `PINNED_MANIFEST_KEY` in test builds), configurable manifest, and a `--latency` flag to exercise backoff.

**`e2e/systemprompt.spec.ts`** (Playwright + Electron):

1. **Sign-in**: PAT, OTC, and device-link flows each reach `signed-in`; device-link asserts the loopback callback rejects a wrong `state`.
2. **Sync latency**: mutate harness manifest, assert renderer reflects the change within the focused 30 s window (test uses a shortened interval via env).
3. **Switching**: two instances, `setActive` swaps content in < 1 s (asserted with `performance.now()` around the IPC round-trip).
4. **Sign-out**: skills/MCP/rules cleared; JWT gone from memory (asserted via a debug IPC that returns only a boolean `hasJwt`).
5. **Plaintext-secret scan**: after all flows, walk `userData` and assert no file contains the PAT or any JWT substring; assert `pat.bin` is non-UTF8 ciphertext.

---

## Open questions before I write code

1. **OTC endpoint shape** — is `/auth/otc` correct for 0.76, or should I confirm against the harness first and adjust PROTOCOL.md?
2. **Pinned key rotation** — do you want a key-id field in the manifest so a future rotation doesn't require an app release, or is a single compiled key acceptable for now?
3. **Patch budget** — 80/250 leaves headroom; do you want the guard to also enforce per-file caps, or just the total?
4. **Service port conflict** — fail-closed on a foreign listener on 17495 is my default; confirm you don't want a fallback port.

Once these are settled I'll produce the actual file contents for each module in the order above.

Source

English

Tools in this stack