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 extensions/vscode/rt-context/src/secretsMapping.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,7 +14,7 @@ export interface DaemonSecretsResponse {
error?: string;
}

/** The daemon's secrets:read verb whitelists exactly these two fields (lib/daemon/handlers/secrets.ts) — keep in lockstep. */
/** The daemon's secrets:read verb's extension scope whitelists exactly these two fields (lib/daemon/handlers/secrets.ts) — keep in lockstep. */
export type DaemonSecretKey = 'linearApiKey' | 'gitlabToken';

/**
Expand Down
109 changes: 109 additions & 0 deletions lib/daemon/__tests__/secrets-handler.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -117,10 +117,12 @@ describe("secrets:forge-token default tracking reader is machine-only", () => {
// has no auth of its own — the handler is the only gate there).
function readHandler(opts: {
extensionSecrets?: () => Promise<{ linearApiKey?: string; gitlabToken?: string }>;
deckSecrets?: () => Promise<{ cfApiToken?: string; cfZoneId?: string }>;
apiToken?: string;
}) {
const h = createSecretsHandlers(fakeCtx, {
extensionSecrets: opts.extensionSecrets ?? (async () => ({})),
deckSecrets: opts.deckSecrets ?? (async () => ({})),
apiToken: () => opts.apiToken ?? "test-token",
});
return h["secrets:read"];
Expand DownExpand Up@@ -179,4 +181,111 @@ describe("secrets:read", () => {

expect((await h({ token: "shared-secret" })).ok).toBe(true);
});

test("omitted scope defaults to extension and never touches the deck reader", async () => {
let deckCalled = false;
const h = readHandler({
extensionSecrets: async () => ({ linearApiKey: "lin_api_x" }),
deckSecrets: async () => { deckCalled = true; return { cfApiToken: "cf-tok" }; },
});

const res = await h({ token: "test-token" });

expect(res).toEqual({ ok: true, data: { linearApiKey: "lin_api_x" } });
expect(deckCalled).toBe(false);
});
});

// scope: "deck" reads a wholly different encrypted domain (lib/secrets/
// store.ts readSecret("deck", ...)) — the whitelist and the domain are both
// per-scope, so an rt-domain value seeded for the extension scope must
// never leak into a deck-scope read, and vice versa.
describe("secrets:read scope", () => {
test("deck scope returns only cfApiToken/cfZoneId, never an rt-domain key seeded for extension scope", async () => {
const h = readHandler({
extensionSecrets: async () => ({ linearApiKey: "lin_api_x", gitlabToken: "glpat-x" }),
deckSecrets: async () => ({ cfApiToken: "cf-tok", cfZoneId: "zone-1" }),
});

const res = await h({ token: "test-token", scope: "deck" });

expect(res).toEqual({ ok: true, data: { cfApiToken: "cf-tok", cfZoneId: "zone-1" } });
expect("linearApiKey" in (res as any).data).toBe(false);
});

test("deck scope omits a key entirely when it isn't set", async () => {
const h = readHandler({ deckSecrets: async () => ({ cfApiToken: "cf-tok" }) });

const res = await h({ token: "test-token", scope: "deck" });

expect(res).toEqual({ ok: true, data: { cfApiToken: "cf-tok" } });
expect("cfZoneId" in (res as any).data).toBe(false);
});

test("extension scope is unchanged when named explicitly", async () => {
const h = readHandler({ extensionSecrets: async () => ({ linearApiKey: "lin_api_x", gitlabToken: "glpat-x" }) });

const res = await h({ token: "test-token", scope: "extension" });

expect(res).toEqual({ ok: true, data: { linearApiKey: "lin_api_x", gitlabToken: "glpat-x" } });
});

test("bad scope is refused before either reader runs", async () => {
let extensionCalled = false;
let deckCalled = false;
const h = readHandler({
extensionSecrets: async () => { extensionCalled = true; return {}; },
deckSecrets: async () => { deckCalled = true; return {}; },
});

const res = await h({ token: "test-token", scope: "bitbucket" as any });

expect(res).toEqual({ ok: false, error: "bad-scope" });
expect(extensionCalled).toBe(false);
expect(deckCalled).toBe(false);
});

test("the token gate applies to the deck scope exactly as it does to extension", async () => {
let deckCalled = false;
const h = readHandler({ deckSecrets: async () => { deckCalled = true; return {}; } });

const missing = await h({ scope: "deck" });
expect(missing).toEqual({ ok: false, error: "missing-token" });

const wrong = await h({ token: "wrong", scope: "deck" });
expect(wrong).toEqual({ ok: false, error: "bad-token" });

expect(deckCalled).toBe(false);
});

test("a case-variant scope (\"Deck\") is refused, not treated as \"deck\"", async () => {
let deckCalled = false;
const h = readHandler({ deckSecrets: async () => { deckCalled = true; return {}; } });

const res = await h({ token: "test-token", scope: "Deck" as any });

expect(res).toEqual({ ok: false, error: "bad-scope" });
expect(deckCalled).toBe(false);
});

test("a non-string scope (array) is refused, not coerced into a match", async () => {
let extensionCalled = false;
let deckCalled = false;
const h = readHandler({
extensionSecrets: async () => { extensionCalled = true; return {}; },
deckSecrets: async () => { deckCalled = true; return {}; },
});

const res = await h({ token: "test-token", scope: ["deck"] as any });

expect(res).toEqual({ ok: false, error: "bad-scope" });
expect(extensionCalled).toBe(false);
expect(deckCalled).toBe(false);
});

test("a deck-reader throw surfaces as a rejected promise (transport error), never a partial ok", async () => {
const h = readHandler({ deckSecrets: async () => { throw new Error("sops -d exploded"); } });

await expect(h({ token: "test-token", scope: "deck" })).rejects.toThrow("sops -d exploded");
});
});
62 changes: 55 additions & 7 deletions lib/daemon/handlers/secrets.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -29,14 +29,47 @@
* X-RT-Token header into the payload so HTTP callers (the extension) don't
* need to change; a socket caller must read ~/.mattstack/rt/api-token
* itself and pass it the same way.
*
* `payload.scope` adds a second whitelist alongside the
* extension's, each reading its own encrypted domain — "extension" (default,
* so existing callers need no change) reads the `rt` domain's linearApiKey/
* gitlabToken; "deck" reads the `deck` domain's cfApiToken/cfZoneId. The
* token gate above applies identically to both; scope is checked only after
* it passes, and an unknown scope is refused before either domain reader
* runs — the two whitelists must never blend into one combined read.
*/

import { loadSecrets } from "../../linear.ts";
import { loadMachineRepoTracking, grants, type RepoTracking } from "../../repo-tracking.ts";
import { loadOrCreateApiToken, tokenOk } from "../api-auth.ts";
import { readSecret, createRealSecretsExecSeam, type SecretsSeams } from "../../secrets/store.ts";
import { createRealAgeKeySeam } from "../../home/age-key.ts";
import type { Commands, ForgeSlug } from "../../../packages/rt-client/src/commands.ts";
import type { HandlerContext, HandlerMap, TypedHandlers } from "./types.ts";

const DECK_SECRET_DOMAIN = "deck";
const DECK_SECRET_KEYS = ["cfApiToken", "cfZoneId"] as const;

let realDeckSecretsSeamsSingleton: SecretsSeams | null = null;

/** Lazily-built real seams, private to this module — a separate instance from lib/linear.ts's own singleton only because module scope doesn't let this file reuse that one, not because the seams themselves are domain-bound (`readSecret` takes `domain` as a parameter). */
function defaultDeckSecretsSeams(): SecretsSeams {
return realDeckSecretsSeamsSingleton ??= {
ageKeySeam: createRealAgeKeySeam(),
execSeam: createRealSecretsExecSeam(),
};
}

async function loadDeckSecrets(): Promise<{ cfApiToken?: string; cfZoneId?: string }> {
const seams = defaultDeckSecretsSeams();
const out: { cfApiToken?: string; cfZoneId?: string } = {};
for (const key of DECK_SECRET_KEYS) {
const value = await readSecret(DECK_SECRET_DOMAIN, key, seams);
if (value !== null) out[key] = value;
}
return out;
}

const SECRETS_KEY: Record<ForgeSlug, "gitlabToken" | "githubToken"> = {
gitlab: "gitlabToken",
github: "githubToken",
Expand All@@ -45,8 +78,10 @@ const SECRETS_KEY: Record<ForgeSlug, "gitlabToken" | "githubToken"> = {
export interface SecretsHandlerOverrides {
tracking?: () => RepoTracking;
secrets?: () => { gitlabToken?: string; githubToken?: string } | Promise<{ gitlabToken?: string; githubToken?: string }>;
/** Defaults to `loadSecrets` (the full encrypted-store + plaintext-fallback loader) for secrets:read. */
/** Defaults to `loadSecrets` (the full encrypted-store + plaintext-fallback loader) for secrets:read's "extension" scope. */
extensionSecrets?: () => Promise<{ linearApiKey?: string; gitlabToken?: string }>;
/** Defaults to `loadDeckSecrets` (the `deck` encrypted domain) for secrets:read's "deck" scope. */
deckSecrets?: () => Promise<{ cfApiToken?: string; cfZoneId?: string }>;
/** Defaults to `loadOrCreateApiToken` (the real ~/.mattstack/rt/api-token, shared with api-auth.ts). */
apiToken?: () => string;
}
Expand All@@ -62,6 +97,7 @@ export function createSecretsHandlers(
const tracking = overrides.tracking ?? loadMachineRepoTracking;
const secrets = overrides.secrets ?? loadSecrets;
const extensionSecrets = overrides.extensionSecrets ?? loadSecrets;
const deckSecrets = overrides.deckSecrets ?? loadDeckSecrets;
const apiToken = overrides.apiToken ?? (() => loadOrCreateApiToken());

return {
Expand DownExpand Up@@ -101,12 +137,24 @@ export function createSecretsHandlers(
return { ok: false as const, error: "bad-token" };
}

const all = await extensionSecrets();
const data: Commands["secrets:read"]["data"] = {};
if (all.linearApiKey) data.linearApiKey = all.linearApiKey;
if (all.gitlabToken) data.gitlabToken = all.gitlabToken;
ctx.log.debug({ keys: Object.keys(data) }, "secrets:read");
return { ok: true as const, data };
const scope = payload?.scope ?? "extension";
if (scope === "extension") {
const all = await extensionSecrets();
const data: { linearApiKey?: string; gitlabToken?: string } = {};
if (all.linearApiKey) data.linearApiKey = all.linearApiKey;
if (all.gitlabToken) data.gitlabToken = all.gitlabToken;
ctx.log.debug({ scope, keys: Object.keys(data) }, "secrets:read");
return { ok: true as const, data };
}
if (scope === "deck") {
const all = await deckSecrets();
const data: { cfApiToken?: string; cfZoneId?: string } = {};
if (all.cfApiToken) data.cfApiToken = all.cfApiToken;
if (all.cfZoneId) data.cfZoneId = all.cfZoneId;
ctx.log.debug({ scope, keys: Object.keys(data) }, "secrets:read");
return { ok: true as const, data };
}
return { ok: false as const, error: "bad-scope" };
},
};
}
3 changes: 2 additions & 1 deletion packages/rt-client/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -40,7 +40,8 @@
},
"scripts": {
"build": "bun build src/index.ts --outdir dist --target node --format esm --packages external && tsc -p tsconfig.json",
"check-types": "tsc --noEmit -p tsconfig.json"
"check-types": "tsc --noEmit -p tsconfig.json",
"prepack": "bun run build"
},
"publishConfig": {
"access": "public"
Expand Down
24 changes: 17 additions & 7 deletions packages/rt-client/src/commands.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -71,19 +71,29 @@ export interface Commands {
*/
"secrets:forge-token": { payload: { repoName: string; forge: ForgeSlug }; data: ForgeTokenData };
/**
* The whitelisted subset of `Secrets` the VS Code extension reads directly
* (RT-32): only linearApiKey and gitlabToken, both optional (present only
* when set). Not a general secrets export — extend the whitelist here, in
* lockstep with lib/daemon/handlers/secrets.ts and
* extensions/vscode/rt-context/src/secrets.ts, if a consumer needs another key.
* A per-`scope` whitelisted subset of secrets, each scope reading its own
* encrypted domain: "extension" (default, so the VS Code extension needs
* no change) is linearApiKey/gitlabToken from the `rt` domain; "deck" is
* cfApiToken/cfZoneId from the `deck` domain. `data` is a union of the two
* exact per-scope shapes, not a merged bag of all four keys — that makes
* a caller narrowing on the wrong scope's fields a compile error instead
* of a silent `undefined`. Both optional per key (present only when set).
* Not a general secrets export — extend a whitelist here, in lockstep
* with lib/daemon/handlers/secrets.ts and (for "extension")
* extensions/vscode/rt-context/src/secrets.ts, if a consumer needs another
* key.
*
* `token` is required and checked in the HANDLER (not a transport-layer
* gate alone), since this verb is reachable over the unauthenticated unix
* socket too — see lib/daemon/handlers/secrets.ts's doc comment. HTTP
* callers get it forwarded automatically from their X-RT-Token header;
* socket callers must read ~/.mattstack/rt/api-token themselves.
* socket callers must read ~/.mattstack/rt/api-token themselves. The gate
* applies identically to every scope.
*/
"secrets:read": { payload: { token?: string }; data: { linearApiKey?: string; gitlabToken?: string } };
"secrets:read": {
payload: { token?: string; scope?: "extension" | "deck" };
data: { linearApiKey?: string; gitlabToken?: string } | { cfApiToken?: string; cfZoneId?: string };
};
"events:emit": { payload: { topic: string; payload?: unknown }; data: { id: number } };
"events:wait": { payload: { pattern: string; after?: number; waitMs?: number }; data: { events: EventsBusEvent[]; cursor: number } };
"events:list": { payload: { pattern: string; after?: number; limit?: number }; data: { events: EventsBusEvent[]; cursor: number } };
Expand Down
18 changes: 15 additions & 3 deletions packages/rt-client/src/transport.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -23,15 +23,27 @@ export interface RtClientOptions {

// Duplicates the ~/.mattstack/rt layout: rt-client has no dependency on rt's
// lib/, so this literal cannot import rtDir(). repo-tools/lib/rt-paths.ts is
// the authority — change there first, mirror here.
export const DEFAULT_SOCK = join(homedir(), ".mattstack", "rt", "rt.sock");
// the authority — change there first, mirror here (same convention as
// settings/paths.ts's call-time `home()`).
function defaultSock(): string {
return join(process.env.HOME ?? homedir(), ".mattstack", "rt", "rt.sock");
}

/**
* Display-only: a module-load snapshot for callers that just want to show
* the default path (no consumer imports it today — checked). `rtCommand`
* itself never reads this constant; it calls `defaultSock()` fresh on every
* invocation so a test can repoint `process.env.HOME` at any time before
* calling, not only before this module first loads.
*/
export const DEFAULT_SOCK = defaultSock();

export async function rtCommand<T = unknown>(
cmd: string,
payload: Record<string, unknown>,
opts: { sockPath?: string; timeoutMs?: number } = {},
): Promise<RtResponse<T>> {
const sockPath = opts.sockPath ?? DEFAULT_SOCK;
const sockPath = opts.sockPath ?? defaultSock();
try {
const res = await fetch(`http://localhost/${cmd}`, {
unix: sockPath,
Expand Down
Loading
Loading