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
1 change: 1 addition & 0 deletions apps/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
"@effect/vitest": "catalog:",
"@pierre/diffs": "1.1.20",
"@ru-fork/branding": "workspace:*",
"@ru-fork/mcp-core": "workspace:*",
"@t3tools/contracts": "workspace:*",
"@t3tools/shared": "workspace:*",
"@t3tools/web": "workspace:*",
Expand Down
10 changes: 10 additions & 0 deletions apps/server/src/atomicWrite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ import * as Random from "effect/Random";
export const writeFileStringAtomically = (input: {
readonly filePath: string;
readonly contents: string;
// ru-fork: optional owner-only perms for secret-bearing files (e.g. the MCP
// overlay). Omitted ⇒ the process umask default (unchanged behaviour).
readonly mode?: number;
readonly dirMode?: number;
}) =>
Effect.scoped(
Effect.gen(function* () {
Expand All @@ -15,13 +19,19 @@ export const writeFileStringAtomically = (input: {
const targetDirectory = path.dirname(input.filePath);

yield* fs.makeDirectory(targetDirectory, { recursive: true });
if (input.dirMode !== undefined) {
yield* fs.chmod(targetDirectory, input.dirMode);
}
const tempDirectory = yield* fs.makeTempDirectoryScoped({
directory: targetDirectory,
prefix: `${path.basename(input.filePath)}.`,
});
const tempPath = path.join(tempDirectory, `${tempFileId}.tmp`);

yield* fs.writeFileString(tempPath, input.contents);
if (input.mode !== undefined) {
yield* fs.chmod(tempPath, input.mode);
}
yield* fs.rename(tempPath, input.filePath);
}),
);
43 changes: 40 additions & 3 deletions apps/server/src/auth/Layers/ServerSecretStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import * as Predicate from "effect/Predicate";
import * as PlatformError from "effect/PlatformError";

import { ServerConfig } from "../../config.ts";
import { decryptSecret, encryptSecret } from "../secretCrypto.ts";
import {
SecretStoreError,
ServerSecretStore,
Expand Down Expand Up @@ -37,7 +38,6 @@ export const makeServerSecretStore = Effect.gen(function* () {

const get: ServerSecretStoreShape["get"] = (name) =>
fileSystem.readFile(resolveSecretPath(name)).pipe(
Effect.map((bytes) => Uint8Array.from(bytes)),
Effect.catch((cause) =>
cause.reason._tag === "NotFound"
? Effect.succeed(null)
Expand All @@ -48,13 +48,25 @@ export const makeServerSecretStore = Effect.gen(function* () {
}),
),
),
// ru-fork #4: at-rest decryption runs AFTER the read-error catch — a NotFound stays null; a
// tampered/unwrappable blob becomes a typed SecretStoreError (Effect.try converts the throw).
Effect.flatMap((bytes) =>
bytes === null
? Effect.succeed(null)
: Effect.try({
try: () => decryptSecret(Uint8Array.from(bytes)),
catch: (cause) =>
new SecretStoreError({ message: `Failed to decrypt secret ${name}.`, cause }),
}),
),
);

const set: ServerSecretStoreShape["set"] = (name, value) => {
const secretPath = resolveSecretPath(name);
const tempPath = `${secretPath}.${Crypto.randomUUID()}.tmp`;
const encrypted = encryptSecret(value); // ru-fork #4: at-rest encryption
return Effect.gen(function* () {
yield* fileSystem.writeFile(tempPath, value);
yield* fileSystem.writeFile(tempPath, encrypted);
yield* fileSystem.chmod(tempPath, 0o600);
yield* fileSystem.rename(tempPath, secretPath);
yield* fileSystem.chmod(secretPath, 0o600);
Expand All @@ -77,13 +89,14 @@ export const makeServerSecretStore = Effect.gen(function* () {

const create: ServerSecretStoreShape["set"] = (name, value) => {
const secretPath = resolveSecretPath(name);
const encrypted = encryptSecret(value); // ru-fork #4: at-rest encryption
return Effect.scoped(
Effect.gen(function* () {
const file = yield* fileSystem.open(secretPath, {
flag: "wx",
mode: 0o600,
});
yield* file.writeAll(value);
yield* file.writeAll(encrypted);
yield* file.sync;
yield* fileSystem.chmod(secretPath, 0o600);
}),
Expand Down Expand Up @@ -141,11 +154,35 @@ export const makeServerSecretStore = Effect.gen(function* () {
),
);

const pruneByPrefix: ServerSecretStoreShape["pruneByPrefix"] = (prefix, keep) =>
Effect.gen(function* () {
const entries = yield* fileSystem
.readDirectory(serverConfig.secretsDir)
.pipe(Effect.catch(() => Effect.succeed<ReadonlyArray<string>>([])));
for (const entry of entries) {
if (!entry.endsWith(".bin")) {
continue;
}
const name = entry.slice(0, -".bin".length);
if (!name.startsWith(prefix) || keep.has(name)) {
continue;
}
// ru-fork: best-effort GC. `force` makes an already-gone file a no-op; a real remove failure is
// logged (not lost) but must NOT abort pruning the remaining orphans, so it's swallowed per file.
yield* fileSystem.remove(path.join(serverConfig.secretsDir, entry), { force: true }).pipe(
Effect.catchCause((cause) =>
Effect.logDebug("mcp secret prune: failed to remove a secret file", { entry, cause }),
),
);
}
});

return {
get,
set,
getOrCreateRandom,
remove,
pruneByPrefix,
} satisfies ServerSecretStoreShape;
});

Expand Down
11 changes: 11 additions & 0 deletions apps/server/src/auth/Services/ServerSecretStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,17 @@ export interface ServerSecretStoreShape {
bytes: number,
) => Effect.Effect<Uint8Array, SecretStoreError>;
readonly remove: (name: string) => Effect.Effect<void, SecretStoreError>;
/**
* Remove every stored secret whose name starts with `prefix` and is NOT in `keep`.
* Used to GC orphaned MCP var secrets (sibling of the probe-cache `deleteKeysNotIn`).
* A missing store directory / individual removal error is swallowed (best-effort GC).
*/
// ru-fork: best-effort GC of orphaned secret files — never fails (a remove failure is logged, not
// surfaced), so the error channel is `never`.
readonly pruneByPrefix: (
prefix: string,
keep: ReadonlySet<string>,
) => Effect.Effect<void>;
}

export class ServerSecretStore extends Context.Service<ServerSecretStore, ServerSecretStoreShape>()(
Expand Down
36 changes: 36 additions & 0 deletions apps/server/src/auth/secretCrypto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
// ru-fork #4: at-rest encryption for the ServerSecretStore `.bin` files. Mirrors qwen-code 0.13.1's
// headless file scheme (file-token-storage.ts): scrypt(host+user) → aes-256-gcm, format
// `ivHex:authTagHex:cipherHex`. Host+user-derived key matches qwen's threat model (protects a
// copied-off-host file, not a local same-user attacker). The MCP feature has never shipped, so there
// is no legacy plaintext to migrate.
import * as Crypto from "node:crypto";
import * as os from "node:os";

const deriveKey = (): Buffer => {
const salt = `${os.hostname()}-${os.userInfo().username}-qwen-code`;
return Crypto.scryptSync("qwen-code-oauth", salt, 32);
};

// Derived once per process (scrypt is intentionally slow); host/user don't change at runtime.
const KEY = deriveKey();

export function encryptSecret(plaintext: Uint8Array): Uint8Array {
const iv = Crypto.randomBytes(16);
const cipher = Crypto.createCipheriv("aes-256-gcm", KEY, iv);
const encrypted = Buffer.concat([cipher.update(Buffer.from(plaintext)), cipher.final()]);
const authTag = cipher.getAuthTag();
const blob = `${iv.toString("hex")}:${authTag.toString("hex")}:${encrypted.toString("hex")}`;
return new TextEncoder().encode(blob);
}

export function decryptSecret(blob: Uint8Array): Uint8Array {
const parts = new TextDecoder().decode(blob).split(":");
if (parts.length !== 3) {
throw new Error("Invalid encrypted secret format");
}
const iv = Buffer.from(parts[0]!, "hex");
const authTag = Buffer.from(parts[1]!, "hex");
const decipher = Crypto.createDecipheriv("aes-256-gcm", KEY, iv);
decipher.setAuthTag(authTag);
return new Uint8Array(Buffer.concat([decipher.update(Buffer.from(parts[2]!, "hex")), decipher.final()]));
}
22 changes: 22 additions & 0 deletions apps/server/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,16 @@ export const SLASH_COMMAND_NOTIFICATION_METHODS: readonly string[] =
*/
export const ACP_SERVER_NO_SSL = true;

/**
* MCP_ENGINE_USE_OVERLAY — ru-fork kill-switch for the MCP overlay engine. When
* true (default), starting a qwen ACP session injects the per-project settings
* overlay (`QWEN_CODE_SYSTEM_SETTINGS_PATH`) + server allowlist
* (`--allowed-mcp-server-names`), and the reactor restarts live sessions on
* fingerprint change. When false, spawns are byte-for-byte identical to today
* (no overlay, no allowlist) — qwen falls back to the user's own ~/.qwen config.
*/
export const MCP_ENGINE_USE_OVERLAY = true;

/**
* STOP_BUTTON_METHOD — what the Stop button (`thread.turn.interrupt`)
* does. "end-force" because CLI builds we ship against can ignore
Expand Down Expand Up @@ -171,6 +181,14 @@ export interface ServerDerivedPaths {
readonly environmentIdPath: string;
readonly serverRuntimeStatePath: string;
readonly secretsDir: string;
// ru-fork: per-project MCP overlay files consumed by qwen via QWEN_CODE_SYSTEM_SETTINGS_PATH.
readonly mcpOverlayDir: string;
// ru-fork: neutral working directory for MCP probes. Probes only verify
// install/reachability + discover tools (cwd-independent), so `${PROJECT_CWD}`
// resolves here instead of any project dir — one probe (and one cache row) per
// authored config, shared across all projects. qwen still gets the REAL project
// cwd at overlay-write time; this dir is never handed to a live session.
readonly mcpProbeCwd: string;
}

/**
Expand Down Expand Up @@ -233,6 +251,8 @@ export const deriveServerPaths = Effect.fn(function* (
environmentIdPath: join(stateDir, "environment-id"),
serverRuntimeStatePath: join(stateDir, "server-runtime.json"),
secretsDir: join(stateDir, "secrets"),
mcpOverlayDir: join(stateDir, "mcp", "overlays"),
mcpProbeCwd: join(stateDir, "mcp", "probe-cwd"),
};
});

Expand All @@ -252,6 +272,8 @@ export const ensureServerDirectories = Effect.fn(function* (derivedPaths: Server
fs.makeDirectory(path.dirname(derivedPaths.settingsPath), { recursive: true }),
fs.makeDirectory(derivedPaths.providerStatusCacheDir, { recursive: true }),
fs.makeDirectory(path.dirname(derivedPaths.serverRuntimeStatePath), { recursive: true }),
fs.makeDirectory(derivedPaths.mcpOverlayDir, { recursive: true }),
fs.makeDirectory(derivedPaths.mcpProbeCwd, { recursive: true }),
],
{ concurrency: "unbounded" },
);
Expand Down
13 changes: 13 additions & 0 deletions apps/server/src/fastShutdown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
* 2. SIGKILL every PTY (`terminalManager.killAll`).
* 3. Remove the persisted runtime-state file so the next launcher run
* doesn't see a stale pid.
* 4. ru-fork #4: sweep the MCP overlay dir (plaintext-secret files) — SYNC,
* since this runs in the SIGINT/SIGTERM handler under Effect.runSync.
*
* Step ordering matters: kill child processes first (those are what
* blocks the node process from exiting fast) then drop bookkeeping.
Expand Down Expand Up @@ -42,4 +44,15 @@ export const runFastShutdownCleanup = Effect.gen(function* () {
// Already absent or another writer touched it — fine.
}
});
// ru-fork #4: drop every per-project overlay (plaintext secrets) on graceful stop,
// shrinking the on-disk window. SYNC (rmSync) because this runs in the SIGINT/SIGTERM
// handler under Effect.runSync — an async FileSystem.remove would throw here.
// Best-effort; the next start's sweep is the backstop for anything left.
yield* Effect.sync(() => {
try {
nodeFs.rmSync(config.mcpOverlayDir, { recursive: true, force: true });
} catch {
// Missing or racing writer — fine; start-sweep nets it.
}
});
});
48 changes: 44 additions & 4 deletions apps/server/src/orchestration/Layers/OrchestrationEngine.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import type {
McpCatalogAggregateId,
OrchestrationEvent,
OrchestrationReadModel,
ProjectId,
ThreadId,
} from "@t3tools/contracts";
import { OrchestrationCommand } from "@t3tools/contracts";
import { MCP_CATALOG_AGGREGATE_ID, OrchestrationCommand } from "@t3tools/contracts";
import * as Cause from "effect/Cause";
import * as Clock from "effect/Clock";
import * as DateTime from "effect/DateTime";
Expand All @@ -30,6 +31,8 @@ import {
import { toPersistenceSqlError } from "../../persistence/Errors.ts";
import { OrchestrationEventStore } from "../../persistence/Services/OrchestrationEventStore.ts";
import { OrchestrationCommandReceiptRepository } from "../../persistence/Services/OrchestrationCommandReceipts.ts";
import { ServerSecretStore } from "../../auth/Services/ServerSecretStore.ts";
import { ServerSecretStoreLive } from "../../auth/Layers/ServerSecretStore.ts";
import {
OrchestrationCommandInvariantError,
OrchestrationCommandPreviouslyRejectedError,
Expand All @@ -56,8 +59,8 @@ interface CommandEnvelope {
}

function commandToAggregateRef(command: OrchestrationCommand): {
readonly aggregateKind: "project" | "thread";
readonly aggregateId: ProjectId | ThreadId;
readonly aggregateKind: "project" | "thread" | "mcp-catalog";
readonly aggregateId: ProjectId | ThreadId | McpCatalogAggregateId;
} {
switch (command.type) {
case "project.create":
Expand All @@ -67,6 +70,22 @@ function commandToAggregateRef(command: OrchestrationCommand): {
aggregateKind: "project",
aggregateId: command.projectId,
};
// ru-fork: MCP catalog commands target the singleton catalog aggregate.
case "mcp.server-add":
case "mcp.server-update":
case "mcp.server-remove":
case "mcp.builtin-sync":
return {
aggregateKind: "mcp-catalog",
aggregateId: MCP_CATALOG_AGGREGATE_ID,
};
// ru-fork: MCP binding commands are project-scoped (cascade with project.deleted).
case "mcp.binding-set":
case "mcp.binding-remove":
return {
aggregateKind: "project",
aggregateId: command.projectId,
};
default:
return {
aggregateKind: "thread",
Expand All @@ -81,6 +100,8 @@ const makeOrchestrationEngine = Effect.gen(function* () {
const commandReceiptRepository = yield* OrchestrationCommandReceiptRepository;
const projectionPipeline = yield* OrchestrationProjectionPipeline;
const projectionSnapshotQuery = yield* ProjectionSnapshotQuery;
// ru-fork: the decider splits MCP draft secrets into the secret store.
const serverSecretStore = yield* ServerSecretStore;

const nowIso = Effect.map(DateTime.now, DateTime.formatIso);
let commandReadModel = createEmptyReadModel(yield* nowIso);
Expand Down Expand Up @@ -151,7 +172,7 @@ const makeOrchestrationEngine = Effect.gen(function* () {
const eventBase = yield* decideOrchestrationCommand({
command: envelope.command,
readModel: commandReadModel,
});
}).pipe(Effect.provideService(ServerSecretStore, serverSecretStore));
const eventBases = Array.isArray(eventBase) ? eventBase : [eventBase];
const committedCommand = yield* sql
.withTransaction(
Expand Down Expand Up @@ -275,6 +296,22 @@ const makeOrchestrationEngine = Effect.gen(function* () {
error: error.message,
})
.pipe(Effect.catch(() => Effect.void));
// ru-fork: surface the rejection in the terminal too — DEBUG, because an invariant
// failure is an EXPECTED user-input rejection (e.g. a duplicate-config add), not a
// system fault. The same message also reaches the UI (cleaned) and the rejected receipt.
yield* Effect.logDebug("orchestration command rejected", {
commandType: envelope.command.type,
commandId: envelope.command.commandId,
detail: error.message,
});
} else {
// ru-fork: a genuine, blocking failure (SQL, projection, defect) — log FULL details as
// an ERROR; this is something significantly broken, not expected user input.
yield* Effect.logError("orchestration command failed", {
commandType: envelope.command.type,
commandId: envelope.command.commandId,
cause: Cause.pretty(exit.cause),
});
}
}

Expand Down Expand Up @@ -322,4 +359,7 @@ const makeOrchestrationEngine = Effect.gen(function* () {
export const OrchestrationEngineLive = Layer.effect(
OrchestrationEngineService,
makeOrchestrationEngine,
).pipe(
// ru-fork: the decider splits MCP draft secrets; memoized → shared with auth.
Layer.provideMerge(ServerSecretStoreLive),
);
Loading
Loading