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
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,4 +69,4 @@

- **Type**: ES Module package for opencode plugin system
- **Target**: Bun runtime, ES2021+
- **Purpose**: Sync global opencode config across machines via GitHub
- **Purpose**: Sync global opencode config across machines via GitHub, with optional secrets support (e.g., 1Password backend)
194 changes: 194 additions & 0 deletions docs/1Password.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
# Fork Feature: 1Password Secrets Backend (NO secrets in git)

## Problem
Upstream `opencode-synced` can sync OpenCode secrets by committing:
- `~/.local/share/opencode/auth.json`
- `~/.local/share/opencode/mcp-auth.json`

We want to keep using the plugin for config/sessions, but **never store tokens in git**.

## Goal
Add an optional secrets backend to this plugin:
- `auth.json` and `mcp-auth.json` are stored in **1Password** (as opaque blobs)
- they are **pulled** from 1Password after syncing config
- they are **pushed** back to 1Password when changed
- they are **never committed** to git (even if `includeSecrets: true`)
- keep everything else working exactly like upstream

## Non-goals
- Don’t parse or reinterpret `auth.json` / `mcp-auth.json` structure.
- Don’t implement a filesystem watcher daemon. Keep it command-based.
- Don’t leak secrets in logs/errors.

## Configuration (add to opencode-synced.jsonc)
Add a new optional config block:

```jsonc
{
"includeSecrets": false,
"secretsBackend": {
"type": "1password",
"vault": "Personal",
"documents": {
"authJson": "opencode-auth.json",
"mcpAuthJson": "opencode-mcp-auth.json"
}
}
}
```

Rules:

If secretsBackend.type is missing, run upstream behavior.

If type === "1password", auth.json and mcp-auth.json must NOT be included in git sync, regardless of includeSecrets.

1Password Storage Approach
Use 1Password Document items to store the raw files.

Required CLI operations (execute via child process; never print file contents):

op document get <name> --vault <vault> --out-file <path>

op document create --vault <vault> <file> --title <name>

op document edit <name> --vault <vault> <file>

Implementation Plan (do in order)
1) Locate current secret sync logic
Search the repo for:

includeSecrets

auth.json / mcp-auth.json

extraSecretPaths

/sync-pull /sync-push
Identify the exact function(s) that assemble the list of paths to copy/commit.

2) Add config typing + validation
Extend config types to include secretsBackend.

Validate:

vault required

documents.authJson required

documents.mcpAuthJson required

documents.authJson and documents.mcpAuthJson must be unique

3) Add a SecretsBackend interface
Internal interface:

pull(): Promise<void> // 1P -> local files

push(): Promise<void> // local files -> 1P

status(): Promise<string> (optional)

4) Implement OnePasswordBackend
Implementation rules:

Use child_process to call op.

Detect if op is installed; return a clear, non-secret error.

For pull:

op document get <name> --vault <vault> --out-file <tmp>

atomically write to target path (write temp + rename)

set restrictive perms (0600) where possible

If document is missing, do not fail hard; just skip.

For push:

if local file doesn’t exist: skip.

create doc if missing; otherwise edit doc.

Files to manage (XDG-aware):

Linux/macOS: ~/.local/share/opencode/auth.json and ~/.local/share/opencode/mcp-auth.json

Windows: %LOCALAPPDATA%\opencode\auth.json and %LOCALAPPDATA%\opencode\mcp-auth.json

5) Wire backend into sync lifecycle
Hook points:

After /sync-pull applies repo changes -> call backend.pull()

After /sync-push successfully commits/pushes (or when no repo changes) -> call backend.push()

Add explicit commands:

/sync-secrets-pull

/sync-secrets-push

/sync-secrets-status

6) Enforce “never commit auth files”
When secretsBackend.type === "1password":

Ensure the git sync path list excludes:

~/.local/share/opencode/auth.json

~/.local/share/opencode/mcp-auth.json

Additionally:

Detect if these files are already tracked in the sync repo.

If yes: stop and print remediation instructions (remove + rewrite history).

7) Change detection (recommended)
Add lightweight hashing:

compute SHA256 of local auth.json and mcp-auth.json

store last pushed hash in plugin state

only call backend.push() when changed (avoid unnecessary 1P calls)

8) QA / Acceptance Tests (manual)
Machine A:

Configure secretsBackend=1password

Run /sync-secrets-push (creates docs if missing)

Run /sync-push (must NOT commit auth files)

Machine B:

/sync-link then /sync-pull

/sync-secrets-pull

Verify OpenCode is authenticated without manual token copy

Update tokens:

Run opencode auth login or OpenCode /connect (updates auth.json)

Run /sync-secrets-push

On machine B run /sync-secrets-pull and verify updated auth works

Security Constraints (strict)
Never print secrets.

Never write secrets into the repo.

Never include secrets in thrown error messages.

Ensure local auth files are chmod 0600 where supported.

If 1Password backend fails, do not destroy local auth files.
4 changes: 3 additions & 1 deletion opencode.json
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
{}
{
"$schema": "https://opencode.ai/config.json"
}
8 changes: 2 additions & 6 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,7 @@
"publishConfig": {
"access": "public"
},
"files": [
"dist"
],
"files": ["dist"],
"dependencies": {
"@opencode-ai/plugin": "1.0.85"
},
Expand All @@ -51,8 +49,6 @@
"prepare": "husky"
},
"lint-staged": {
"*.{js,ts,json}": [
"biome check --write --no-errors-on-unmatched"
]
"*.{js,ts,json}": ["biome check --write --no-errors-on-unmatched"]
}
}
5 changes: 5 additions & 0 deletions src/command/sync-secrets-pull.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
description: Pull secrets from the configured backend
---

Use the opencode_sync tool with command "secrets-pull".
5 changes: 5 additions & 0 deletions src/command/sync-secrets-push.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
description: Push secrets to the configured backend
---

Use the opencode_sync tool with command "secrets-push".
5 changes: 5 additions & 0 deletions src/command/sync-secrets-status.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
description: Show secrets backend status
---

Use the opencode_sync tool with command "secrets-status".
22 changes: 21 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,18 @@ export const opencodeConfigSync: Plugin = async (ctx) => {
description: 'Manage opencode config sync with a GitHub repo',
args: {
command: tool.schema
.enum(['status', 'init', 'link', 'pull', 'push', 'enable-secrets', 'resolve'])
.enum([
'status',
'init',
'link',
'pull',
'push',
'enable-secrets',
'resolve',
'secrets-pull',
'secrets-push',
'secrets-status',
])
.describe('Sync command to execute'),
repo: tool.schema.string().optional().describe('Repo owner/name or URL'),
owner: tool.schema.string().optional().describe('Repo owner'),
Expand Down Expand Up @@ -182,6 +193,15 @@ export const opencodeConfigSync: Plugin = async (ctx) => {
if (args.command === 'push') {
return await service.push();
}
if (args.command === 'secrets-pull') {
return await service.secretsPull();
}
if (args.command === 'secrets-push') {
return await service.secretsPush();
}
if (args.command === 'secrets-status') {
return await service.secretsStatus();
}
if (args.command === 'enable-secrets') {
return await service.enableSecrets({
extraSecretPaths: args.extraSecretPaths,
Expand Down
41 changes: 40 additions & 1 deletion src/sync/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,12 @@ import os from 'node:os';
import path from 'node:path';

import { describe, expect, it } from 'vitest';

import type { SyncConfig } from './config.js';
import {
canCommitMcpSecrets,
chmodIfExists,
deepMerge,
normalizeSecretsBackend,
normalizeSyncConfig,
parseJsonc,
stripOverrides,
Expand Down Expand Up @@ -77,6 +78,44 @@ describe('normalizeSyncConfig', () => {
const normalized = normalizeSyncConfig({});
expect(normalized.includeModelFavorites).toBe(true);
});

it('defaults extra path lists when omitted', () => {
const normalized = normalizeSyncConfig({ includeSecrets: true });
expect(normalized.extraSecretPaths).toEqual([]);
expect(normalized.extraConfigPaths).toEqual([]);
});
});

describe('normalizeSecretsBackend', () => {
it('returns undefined when backend is missing', () => {
expect(normalizeSecretsBackend(undefined)).toBeUndefined();
});

it('preserves unknown backend types for validation', () => {
const unknownBackend = { type: 'unknown' } as unknown as SyncConfig['secretsBackend'];
expect(normalizeSecretsBackend(unknownBackend)).toEqual({ type: 'unknown' });
});

it('normalizes 1password documents', () => {
const raw = {
type: '1password',
vault: 'Personal',
documents: {
authJson: 'auth.json',
mcpAuthJson: 'mcp-auth.json',
extra: 'ignored',
},
} as unknown as SyncConfig['secretsBackend'];

expect(normalizeSecretsBackend(raw)).toEqual({
type: '1password',
vault: 'Personal',
documents: {
authJson: 'auth.json',
mcpAuthJson: 'mcp-auth.json',
},
});
});
});

describe('canCommitMcpSecrets', () => {
Expand Down
Loading
Loading