mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-07-12 21:02:17 +02:00
feat(apps): M3 groundwork — packager, GitHub device-flow auth, registry client
- env: GITHUB_OAUTH_CLIENT_ID (device flow enabled on the Rowboat OAuth app;
overridable via ROWBOAT_GITHUB_CLIENT_ID)
- packager (§4.4): allowlist-only .rowboat-app ZIP (yazl), sorted entries,
symlink skip, sha256
- github-auth (§10): device-code start/poll, identity fetch, token stored
0600 with safeStorage encryption injected from main (core stays
electron-free); githubAuth:* IPC + external open of the verification page
- registry client (§9.2): unauthenticated tarball index with 5-min cache and
stale fallback, raw-record resolve, substring search, quota-free
latestManifest via release-asset redirect with name-mismatch guard
- registry repo contents (docs/apps-registry): record JSON schema +
validate-and-merge Action implementing §9.3 checks 1-7 with rejected:<code>
comments and per-name concurrency
- fix: host-api copilot-run adapted to dev's ModelSelection {provider,model}
(this is the same fix dev needs for Ramnique's packaging break)
- pnpm 11: blockExoticSubdeps=false (electron-forge has a git subdep)
This commit is contained in:
parent
b7d1019538
commit
d7af51c10b
13 changed files with 739 additions and 13 deletions
158
apps/x/packages/core/src/apps/github-auth.ts
Normal file
158
apps/x/packages/core/src/apps/github-auth.ts
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { WorkDir } from '../config/config.js';
|
||||
import { GITHUB_OAUTH_CLIENT_ID } from '../config/env.js';
|
||||
|
||||
// GitHub authentication via the OAuth device flow (spec §10). Sign-in is
|
||||
// required ONLY for publishing; create/run/install/update never touch it.
|
||||
// The token doubles as publisher identity (D6).
|
||||
|
||||
const AUTH_FILE = path.join(WorkDir, 'config', 'github-auth.json');
|
||||
|
||||
// Token-at-rest encryption is provided by the Electron main process
|
||||
// (safeStorage) — core stays electron-free. When no cipher is wired (or the OS
|
||||
// keychain is unavailable) the token is stored plaintext with a marker,
|
||||
// matching the existing token-storage approach in auth/.
|
||||
export interface TokenCipher {
|
||||
isAvailable(): boolean;
|
||||
encrypt(plain: string): string; // returns base64
|
||||
decrypt(encrypted: string): string;
|
||||
}
|
||||
let cipher: TokenCipher | null = null;
|
||||
export function setTokenCipher(c: TokenCipher): void {
|
||||
cipher = c;
|
||||
}
|
||||
|
||||
type StoredAuth = {
|
||||
login: string;
|
||||
createdAt: string;
|
||||
token?: string; // plaintext fallback
|
||||
tokenEncrypted?: string; // base64 via cipher
|
||||
plaintext?: boolean;
|
||||
};
|
||||
|
||||
type PendingFlow = {
|
||||
deviceCode: string;
|
||||
intervalMs: number;
|
||||
expiresAt: number;
|
||||
};
|
||||
let pending: PendingFlow | null = null;
|
||||
|
||||
async function readAuth(): Promise<StoredAuth | null> {
|
||||
try {
|
||||
return JSON.parse(await fs.readFile(AUTH_FILE, 'utf-8')) as StoredAuth;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function writeAuth(auth: StoredAuth): Promise<void> {
|
||||
await fs.mkdir(path.dirname(AUTH_FILE), { recursive: true });
|
||||
await fs.writeFile(AUTH_FILE, JSON.stringify(auth, null, 2), { mode: 0o600 });
|
||||
}
|
||||
|
||||
/** Start the device flow. Returns the code the user enters on github.com. */
|
||||
export async function startDeviceFlow(): Promise<{ userCode: string; verificationUri: string; expiresIn: number }> {
|
||||
const res = await fetch('https://github.com/login/device/code', {
|
||||
method: 'POST',
|
||||
headers: { 'Accept': 'application/json', 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ client_id: GITHUB_OAUTH_CLIENT_ID, scope: 'public_repo' }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`device_code_failed: HTTP ${res.status}`);
|
||||
const body = await res.json() as {
|
||||
device_code: string; user_code: string; verification_uri: string;
|
||||
expires_in: number; interval: number;
|
||||
};
|
||||
pending = {
|
||||
deviceCode: body.device_code,
|
||||
intervalMs: (body.interval || 5) * 1000,
|
||||
expiresAt: Date.now() + body.expires_in * 1000,
|
||||
};
|
||||
return { userCode: body.user_code, verificationUri: body.verification_uri, expiresIn: body.expires_in };
|
||||
}
|
||||
|
||||
export type PollResult =
|
||||
| { status: 'pending' }
|
||||
| { status: 'authorized'; login: string }
|
||||
| { status: 'expired' }
|
||||
| { status: 'denied' };
|
||||
|
||||
/** Poll once for device-flow completion (renderer drives the cadence). */
|
||||
export async function pollDeviceFlow(): Promise<PollResult> {
|
||||
if (!pending) return { status: 'expired' };
|
||||
if (Date.now() > pending.expiresAt) {
|
||||
pending = null;
|
||||
return { status: 'expired' };
|
||||
}
|
||||
|
||||
const res = await fetch('https://github.com/login/oauth/access_token', {
|
||||
method: 'POST',
|
||||
headers: { 'Accept': 'application/json', 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
client_id: GITHUB_OAUTH_CLIENT_ID,
|
||||
device_code: pending.deviceCode,
|
||||
grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
|
||||
}),
|
||||
});
|
||||
const body = await res.json() as { access_token?: string; error?: string };
|
||||
|
||||
if (body.error === 'authorization_pending') return { status: 'pending' };
|
||||
if (body.error === 'slow_down') {
|
||||
pending.intervalMs += 5000;
|
||||
return { status: 'pending' };
|
||||
}
|
||||
if (body.error === 'expired_token') {
|
||||
pending = null;
|
||||
return { status: 'expired' };
|
||||
}
|
||||
if (body.error === 'access_denied') {
|
||||
pending = null;
|
||||
return { status: 'denied' };
|
||||
}
|
||||
if (!body.access_token) return { status: 'pending' };
|
||||
|
||||
// Identity: cache login alongside the token.
|
||||
const userRes = await fetch('https://api.github.com/user', {
|
||||
headers: { 'Authorization': `Bearer ${body.access_token}`, 'Accept': 'application/vnd.github+json' },
|
||||
});
|
||||
if (!userRes.ok) throw new Error(`identity_failed: HTTP ${userRes.status}`);
|
||||
const user = await userRes.json() as { login: string };
|
||||
|
||||
const auth: StoredAuth = { login: user.login, createdAt: new Date().toISOString() };
|
||||
if (cipher?.isAvailable()) {
|
||||
auth.tokenEncrypted = cipher.encrypt(body.access_token);
|
||||
} else {
|
||||
auth.token = body.access_token;
|
||||
auth.plaintext = true;
|
||||
}
|
||||
await writeAuth(auth);
|
||||
pending = null;
|
||||
return { status: 'authorized', login: user.login };
|
||||
}
|
||||
|
||||
export async function getAuthStatus(): Promise<{ signedIn: boolean; login?: string }> {
|
||||
const auth = await readAuth();
|
||||
return auth ? { signedIn: true, login: auth.login } : { signedIn: false };
|
||||
}
|
||||
|
||||
/** The stored token, or null. Callers hitting a 401 MUST call clearAuth(). */
|
||||
export async function getGithubToken(): Promise<{ token: string; login: string } | null> {
|
||||
const auth = await readAuth();
|
||||
if (!auth) return null;
|
||||
if (auth.tokenEncrypted && cipher?.isAvailable()) {
|
||||
try {
|
||||
return { token: cipher.decrypt(auth.tokenEncrypted), login: auth.login };
|
||||
} catch {
|
||||
await clearAuth();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
if (auth.token) return { token: auth.token, login: auth.login };
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Sign out / expire (a 401 from any GitHub call surfaces as github_auth_expired). */
|
||||
export async function clearAuth(): Promise<void> {
|
||||
pending = null;
|
||||
await fs.rm(AUTH_FILE, { force: true });
|
||||
}
|
||||
|
|
@ -354,10 +354,11 @@ async function handleCopilotRun(
|
|||
// Headless tool profile: the background-task agent (no shell, no
|
||||
// ask-human/interactive tools) — the same runtime scheduled agents use.
|
||||
// The run is recorded as a normal attributed turn (visible in history).
|
||||
const model = await getBackgroundTaskAgentModel();
|
||||
const selection = await getBackgroundTaskAgentModel();
|
||||
const run = await createRun({
|
||||
agentId: 'background-task-agent',
|
||||
model,
|
||||
model: selection.model,
|
||||
provider: selection.provider,
|
||||
useCase: 'app_copilot_run',
|
||||
subUseCase: slug,
|
||||
});
|
||||
|
|
|
|||
120
apps/x/packages/core/src/apps/packager.ts
Normal file
120
apps/x/packages/core/src/apps/packager.ts
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
import fs from 'node:fs';
|
||||
import fsp from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import crypto from 'node:crypto';
|
||||
import yazl from 'yazl';
|
||||
import { RowboatAppManifestSchema, type RowboatAppManifest } from '@x/shared/dist/rowboat-app.js';
|
||||
import { appDir } from './indexer.js';
|
||||
|
||||
// Packager (spec §4.4): builds the `<name>.rowboat-app` ZIP from the ALLOWLIST
|
||||
// only — rowboat-app.json, dist/**, agents/<manifest.agents>, defaults/** — in
|
||||
// sorted path order (determinism). Personal data cannot leak by omission (D15):
|
||||
// src/, package.json, node_modules/, data/, dotfiles, and .rowboat-*.json are
|
||||
// simply never on the list. Symlinks are skipped with a warning, never followed.
|
||||
|
||||
export class PackageError extends Error {
|
||||
readonly code: string;
|
||||
constructor(code: string, message: string) {
|
||||
super(message);
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
/** Recursively collect files under root; returns package-relative POSIX paths. */
|
||||
async function collectFiles(absRoot: string, relPrefix: string, warnings: string[]): Promise<string[]> {
|
||||
const out: string[] = [];
|
||||
let entries;
|
||||
try {
|
||||
entries = await fsp.readdir(absRoot, { withFileTypes: true });
|
||||
} catch {
|
||||
return out;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
const abs = path.join(absRoot, entry.name);
|
||||
const rel = `${relPrefix}/${entry.name}`;
|
||||
if (entry.isSymbolicLink()) {
|
||||
warnings.push(`skipped symlink: ${rel}`);
|
||||
continue;
|
||||
}
|
||||
if (entry.isDirectory()) {
|
||||
out.push(...await collectFiles(abs, rel, warnings));
|
||||
} else if (entry.isFile()) {
|
||||
out.push(rel);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export interface PackageResult {
|
||||
/** Absolute path of the written bundle. */
|
||||
bundlePath: string;
|
||||
/** Lowercase-hex SHA-256 of the finished ZIP bytes. */
|
||||
sha256: string;
|
||||
manifest: RowboatAppManifest;
|
||||
/** Package-relative paths included, in the order written. */
|
||||
files: string[];
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Build `<name>.rowboat-app` for the app at `folder`, writing the bundle to
|
||||
* `outDir` (created if needed).
|
||||
*/
|
||||
export async function packageApp(folder: string, outDir: string): Promise<PackageResult> {
|
||||
const dir = appDir(folder);
|
||||
|
||||
// 1. Manifest must parse and dist/<entry> must exist.
|
||||
let manifest: RowboatAppManifest;
|
||||
try {
|
||||
manifest = RowboatAppManifestSchema.parse(
|
||||
JSON.parse(await fsp.readFile(path.join(dir, 'rowboat-app.json'), 'utf-8')),
|
||||
);
|
||||
} catch (e) {
|
||||
throw new PackageError('invalid_manifest', `rowboat-app.json is missing or invalid: ${e instanceof Error ? e.message : String(e)}`);
|
||||
}
|
||||
const entryAbs = path.join(dir, 'dist', manifest.entry);
|
||||
if (!fs.existsSync(entryAbs) || !fs.statSync(entryAbs).isFile()) {
|
||||
throw new PackageError('missing_entry', `dist/${manifest.entry} does not exist`);
|
||||
}
|
||||
|
||||
// 2. Assemble the allowlist, sorted for determinism.
|
||||
const warnings: string[] = [];
|
||||
const files: string[] = ['rowboat-app.json'];
|
||||
files.push(...(await collectFiles(path.join(dir, 'dist'), 'dist', warnings)).sort());
|
||||
// agents/: ONLY files listed in manifest.agents (and they must exist).
|
||||
for (const agentFile of [...manifest.agents].sort()) {
|
||||
const abs = path.join(dir, 'agents', agentFile);
|
||||
if (!fs.existsSync(abs) || !fs.statSync(abs).isFile()) {
|
||||
throw new PackageError('missing_agent', `agents/${agentFile} is listed in the manifest but missing`);
|
||||
}
|
||||
files.push(`agents/${agentFile}`);
|
||||
}
|
||||
files.push(...(await collectFiles(path.join(dir, 'defaults'), 'defaults', warnings)).sort());
|
||||
|
||||
// 3. Write the ZIP.
|
||||
await fsp.mkdir(outDir, { recursive: true });
|
||||
const bundlePath = path.join(outDir, `${manifest.name}.rowboat-app`);
|
||||
const zip = new yazl.ZipFile();
|
||||
for (const rel of files) {
|
||||
zip.addFile(path.join(dir, ...rel.split('/')), rel);
|
||||
}
|
||||
zip.end();
|
||||
|
||||
const hash = crypto.createHash('sha256');
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const out = fs.createWriteStream(bundlePath);
|
||||
zip.outputStream.on('data', (chunk: Buffer) => hash.update(chunk));
|
||||
zip.outputStream.pipe(out);
|
||||
out.on('close', () => resolve());
|
||||
out.on('error', reject);
|
||||
zip.outputStream.on('error', reject);
|
||||
});
|
||||
|
||||
return {
|
||||
bundlePath,
|
||||
sha256: hash.digest('hex'),
|
||||
manifest,
|
||||
files,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
146
apps/x/packages/core/src/apps/registry.ts
Normal file
146
apps/x/packages/core/src/apps/registry.ts
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import zlib from 'node:zlib';
|
||||
import {
|
||||
RegistryRecordSchema,
|
||||
RowboatAppManifestSchema,
|
||||
type RegistryRecord,
|
||||
type RowboatAppManifest,
|
||||
} from '@x/shared/dist/rowboat-app.js';
|
||||
import { REGISTRY_REPO, REGISTRY_BRANCH, CATALOG_CACHE_PATH, CATALOG_TTL_MS } from './constants.js';
|
||||
|
||||
// Registry client (spec §9.2). All registry access goes through RegistryClient —
|
||||
// this is the backend swap seam (D5). The GitHub implementation reads the
|
||||
// registry repo as one unauthenticated tarball and resolves versions via
|
||||
// release-asset URLs (plain HTTPS redirects, no API quota).
|
||||
|
||||
export interface RegisterResult {
|
||||
status: 'published' | 'pending' | 'rejected';
|
||||
prUrl?: string;
|
||||
rejectionCode?: string;
|
||||
}
|
||||
|
||||
export interface RegistryClient {
|
||||
refreshIndex(force?: boolean): Promise<{ records: RegistryRecord[]; stale: boolean; fetchedAt: string }>;
|
||||
resolve(name: string): Promise<RegistryRecord | null>;
|
||||
search(query: string): Promise<RegistryRecord[]>;
|
||||
latestManifest(record: RegistryRecord): Promise<RowboatAppManifest>;
|
||||
}
|
||||
|
||||
type CatalogCache = { fetchedAt: string; records: RegistryRecord[] };
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Minimal tar reader — enough to pull apps/*.json out of a codeload tarball.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function* tarEntries(tarBuf: Buffer): Generator<{ name: string; body: Buffer }> {
|
||||
let offset = 0;
|
||||
while (offset + 512 <= tarBuf.length) {
|
||||
const header = tarBuf.subarray(offset, offset + 512);
|
||||
if (header.every((b) => b === 0)) break; // end-of-archive
|
||||
const name = header.subarray(0, 100).toString('utf-8').replace(/\0.*$/, '');
|
||||
const sizeOctal = header.subarray(124, 136).toString('utf-8').replace(/\0.*$/, '').trim();
|
||||
const size = parseInt(sizeOctal, 8) || 0;
|
||||
const body = tarBuf.subarray(offset + 512, offset + 512 + size);
|
||||
yield { name, body: Buffer.from(body) };
|
||||
offset += 512 + Math.ceil(size / 512) * 512;
|
||||
}
|
||||
}
|
||||
|
||||
async function readCache(): Promise<CatalogCache | null> {
|
||||
try {
|
||||
return JSON.parse(await fs.readFile(CATALOG_CACHE_PATH, 'utf-8')) as CatalogCache;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GitHub implementation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export class GitHubRegistryClient implements RegistryClient {
|
||||
async refreshIndex(force = false): Promise<{ records: RegistryRecord[]; stale: boolean; fetchedAt: string }> {
|
||||
const cache = await readCache();
|
||||
if (!force && cache && Date.now() - new Date(cache.fetchedAt).getTime() < CATALOG_TTL_MS) {
|
||||
return { records: cache.records, stale: false, fetchedAt: cache.fetchedAt };
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`https://codeload.github.com/${REGISTRY_REPO}/tar.gz/${REGISTRY_BRANCH}`);
|
||||
if (!res.ok) throw new Error(`registry tarball: HTTP ${res.status}`);
|
||||
const gz = Buffer.from(await res.arrayBuffer());
|
||||
const tar = zlib.gunzipSync(gz);
|
||||
|
||||
const records: RegistryRecord[] = [];
|
||||
for (const entry of tarEntries(tar)) {
|
||||
// Entries look like "<repo>-<branch>/apps/<name>.json".
|
||||
const m = /^[^/]+\/apps\/([^/]+)\.json$/.exec(entry.name);
|
||||
if (!m) continue;
|
||||
try {
|
||||
const parsed = RegistryRecordSchema.safeParse(JSON.parse(entry.body.toString('utf-8')));
|
||||
if (parsed.success) {
|
||||
records.push(parsed.data);
|
||||
} else {
|
||||
console.warn(`[Apps] registry record ${m[1]} failed schema; skipping`);
|
||||
}
|
||||
} catch {
|
||||
console.warn(`[Apps] registry record ${m[1]} is invalid JSON; skipping`);
|
||||
}
|
||||
}
|
||||
records.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
const fetchedAt = new Date().toISOString();
|
||||
await fs.mkdir(path.dirname(CATALOG_CACHE_PATH), { recursive: true });
|
||||
await fs.writeFile(CATALOG_CACHE_PATH, JSON.stringify({ fetchedAt, records } satisfies CatalogCache, null, 2));
|
||||
return { records, stale: false, fetchedAt };
|
||||
} catch (err) {
|
||||
// Network failure with a cache present → serve cache, marked stale.
|
||||
if (cache) return { records: cache.records, stale: true, fetchedAt: cache.fetchedAt };
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async resolve(name: string): Promise<RegistryRecord | null> {
|
||||
const cache = await readCache();
|
||||
if (cache && Date.now() - new Date(cache.fetchedAt).getTime() < CATALOG_TTL_MS) {
|
||||
return cache.records.find((r) => r.name === name) ?? null;
|
||||
}
|
||||
try {
|
||||
const res = await fetch(`https://raw.githubusercontent.com/${REGISTRY_REPO}/${REGISTRY_BRANCH}/apps/${name}.json`);
|
||||
if (res.status === 404) return null;
|
||||
if (!res.ok) throw new Error(`registry record: HTTP ${res.status}`);
|
||||
const parsed = RegistryRecordSchema.safeParse(await res.json());
|
||||
return parsed.success ? parsed.data : null;
|
||||
} catch {
|
||||
// Fall back to any cache we have, however old.
|
||||
return cache?.records.find((r) => r.name === name) ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
async search(query: string): Promise<RegistryRecord[]> {
|
||||
const { records } = await this.refreshIndex();
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return records;
|
||||
return records.filter((r) =>
|
||||
r.name.toLowerCase().includes(q) || r.description.toLowerCase().includes(q));
|
||||
}
|
||||
|
||||
/**
|
||||
* Latest version's manifest via the release-asset URL — a plain HTTPS
|
||||
* redirect, NOT the REST API, so it costs no unauthenticated API quota.
|
||||
*/
|
||||
async latestManifest(record: RegistryRecord): Promise<RowboatAppManifest> {
|
||||
const res = await fetch(`https://github.com/${record.repo}/releases/latest/download/rowboat-app.json`, {
|
||||
redirect: 'follow',
|
||||
});
|
||||
if (!res.ok) throw new Error(`latest_manifest_unavailable: HTTP ${res.status}`);
|
||||
const manifest = RowboatAppManifestSchema.parse(await res.json());
|
||||
if (manifest.name !== record.name) {
|
||||
throw new Error(`name_mismatch: release manifest says "${manifest.name}" but the registry record is "${record.name}"`);
|
||||
}
|
||||
return manifest;
|
||||
}
|
||||
}
|
||||
|
||||
export const registryClient: RegistryClient = new GitHubRegistryClient();
|
||||
|
|
@ -1,2 +1,7 @@
|
|||
export const API_URL =
|
||||
process.env.API_URL || 'https://api.x.rowboatlabs.com';
|
||||
|
||||
// GitHub OAuth app used for Apps publishing (device flow, public_repo scope).
|
||||
// Client IDs are public identifiers, not secrets (spec §3).
|
||||
export const GITHUB_OAUTH_CLIENT_ID =
|
||||
process.env.ROWBOAT_GITHUB_CLIENT_ID || 'Ov23liAka106zKEovj4B';
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue