diff --git a/src/commands/editCommand.ts b/src/commands/editCommand.ts index 87c7450..42188ca 100644 --- a/src/commands/editCommand.ts +++ b/src/commands/editCommand.ts @@ -13,6 +13,7 @@ import { formatRelativeTime } from '../ui/relativeTime'; import { Encryptor } from '../crypto/encryptor'; import { deriveResourceId } from '../crypto/resourceId'; import { setSyncKeepHash } from '../types/index'; +import { isReservedNamespace } from '../core/reservedNamespace'; const B = (s: string) => `\x1b[1m${s}\x1b[0m`; @@ -178,6 +179,9 @@ export class EditCommand { const rows: EditRow[] = []; for (const key of Array.from(allKeys).sort()) { + // Reserved namespace is cloud-managed and read-only — never offered for + // local editing. + if (isReservedNamespace(key)) continue; const localVal = localPlaintext[key]; const remoteVal = remotePlaintext[key]; const pinnedHash = pinned[key]; diff --git a/src/commands/runCommand.ts b/src/commands/runCommand.ts index 60128fe..993f3a7 100644 --- a/src/commands/runCommand.ts +++ b/src/commands/runCommand.ts @@ -3,6 +3,24 @@ import { mkdirSync, writeFileSync, readFileSync, existsSync } from 'fs'; import { join } from 'path'; import { FileManager } from '../files/fileManager'; import { debug } from '../ui/debug'; +import { isReservedNamespace } from '../core/reservedNamespace'; + +/** + * Drops the cloud-authoritative reserved namespace from an env map. These + * variables are the managing runtime's own tooling, not the application's + * secrets, so they never reach a `capy run` child process nor build-time + * inlining. See {@link isReservedNamespace}. + */ +export function stripReservedNamespace( + env: Record, +): Record { + const out: Record = {}; + for (const [k, v] of Object.entries(env)) { + if (isReservedNamespace(k)) continue; + out[k] = v; + } + return out; +} /** * Writes `.capy/next-env.js`, a CommonJS module mapping each decrypted env var @@ -35,6 +53,9 @@ function emitNextEnvModule(keys: string[]): void { * and resolves with its exit code. Shared between local and deployed modes. */ function spawnChild(args: string[], env: Record): Promise { + // The reserved namespace is cloud-authoritative tooling, never the app's + // own secrets — strip it before it can reach the child process. + const childEnv = stripReservedNamespace(env); let child: ChildProcess | null = null; for (const sig of ['SIGTERM', 'SIGINT', 'SIGHUP'] as const) { process.on(sig, () => child?.kill(sig)); @@ -44,7 +65,7 @@ function spawnChild(args: string[], env: Record): Pr // won't execute without a shell — bare `cross-env`/`nodemon` fail with // ENOENT. shell:true delegates to cmd.exe so PATHEXT resolution kicks in. child = spawn(args[0], args.slice(1), { - env: env as Record, + env: childEnv as Record, stdio: 'inherit', shell: process.platform === 'win32', }); @@ -102,7 +123,7 @@ export async function runCommand(args: string[], devMode: boolean = false): Prom // Auto-emit .capy/next-env.js for Next.js build-time inlining. Best-effort // — failure to write is non-fatal (e.g., read-only FS in exotic envs). try { - emitNextEnvModule(Object.keys(envMap)); + emitNextEnvModule(Object.keys(envMap).filter((k) => !isReservedNamespace(k))); } catch { // No-op. next-env.js is a convenience; the child still gets the env. } diff --git a/src/core/reservedNamespace.ts b/src/core/reservedNamespace.ts new file mode 100644 index 0000000..5d9bef1 --- /dev/null +++ b/src/core/reservedNamespace.ts @@ -0,0 +1,20 @@ +/** + * The `_SAGE_*` namespace is reserved for variables that a Capy cloud runtime + * manages on your behalf (the `_SAGE_CONNECTOR_*` family being the first such + * use). The cloud copy is authoritative, so the CLI treats every variable in + * this namespace as read-only locally: + * + * - never injected into a `capy run` child process (it is the runtime's own + * tooling, not the application's secrets), + * - never reconciled by sync — no push, pull, conflict, or local-delete + * propagation; the cloud value always wins, and + * - never editable in the `capy edit` screen. + * + * Keeping the rule in one predicate means every surface enforces it the same + * way and a new surface can opt in with a single import. + */ +export const SAGE_RESERVED_PREFIX = '_SAGE_'; + +export function isReservedNamespace(name: string): boolean { + return name.startsWith(SAGE_RESERVED_PREFIX); +} diff --git a/src/sync/syncEngine.ts b/src/sync/syncEngine.ts index d44d4b5..0155a1f 100644 --- a/src/sync/syncEngine.ts +++ b/src/sync/syncEngine.ts @@ -9,6 +9,7 @@ import { KeepVariableEntry, SyncState, } from '../types/index'; +import { isReservedNamespace } from '../core/reservedNamespace'; export class SyncEngine { /** @@ -44,6 +45,9 @@ export class SyncEngine { // Check local variables for (const key of localKeys) { + // Reserved namespace is cloud-authoritative: the CLI never pushes, + // conflicts, or otherwise reconciles these — the cloud value wins. + if (isReservedNamespace(key)) continue; if (!remoteKeys.has(key)) { // New local variable newLocal.push({ @@ -92,6 +96,9 @@ export class SyncEngine { // Check remote variables not in local for (const key of remoteKeys) { + // Reserved namespace is cloud-authoritative — never pulled into the + // local working set nor flagged as a local deletion. + if (isReservedNamespace(key)) continue; // Skip deleted markers if they don't exist locally (already handled above) if (!localKeys.has(key) && remote[key] !== 'capy:deleted') { // Check if this variable was previously synced to this machine diff --git a/tests/commands/runCommand.test.ts b/tests/commands/runCommand.test.ts index a5e4bea..dc5ddf5 100644 --- a/tests/commands/runCommand.test.ts +++ b/tests/commands/runCommand.test.ts @@ -147,6 +147,20 @@ describe('capy run', () => { expect(result.exitCode).toBe(0); expect(result.stdout.trim()).toBe('localhost:5432'); }); + + test('strips the reserved _SAGE_* namespace from the child env (keeps app vars)', async () => { + writeFileSync(join(TEST_DIR, '.env'), '_SAGE_CONNECTOR_SENDGRID=manager-key\nAPP_VAR=visible\n'); + + const result = await capy([ + '--', 'node', '-e', + 'console.log(JSON.stringify({ sage: process.env._SAGE_CONNECTOR_SENDGRID ?? null, app: process.env.APP_VAR ?? null }))', + ]); + + expect(result.exitCode).toBe(0); + const out = JSON.parse(result.stdout.trim()); + expect(out.app).toBe('visible'); + expect(out.sage).toBeNull(); + }); }); // --------------------------------------------------------------------------- @@ -306,6 +320,33 @@ describe('capy run (deployed mode)', () => { expect(result.stderr).toMatch(/not found on .* different Capy service/); }); + test('strips the reserved _SAGE_* namespace from both the child env and next-env.js', async () => { + const envVars = { _SAGE_CONNECTOR_SENDGRID: 'manager-key', APP_VAR: 'visible' }; + const { pk, secretsBlob, serviceKeyHex } = buildDeployedFixture(envVars); + fake = await startFakeService(serviceKeyHex); + + const result = await capy([ + '--', 'node', '-e', + 'console.log(JSON.stringify({ sage: process.env._SAGE_CONNECTOR_SENDGRID ?? null, app: process.env.APP_VAR ?? null }))', + ], { + env: { + SECRETS_BLOB: secretsBlob, + PROJECT_KEY: pk.toString('hex'), + CAPY_API_URL: fake.url, + }, + }); + + expect(result.exitCode).toBe(0); + const out = JSON.parse(result.stdout.trim()); + expect(out.app).toBe('visible'); + expect(out.sage).toBeNull(); + + // The build-time inlining module must not leak the reserved var either. + const content = readFileSync(join(TEST_DIR, '.capy', 'next-env.js'), 'utf-8'); + expect(content).toContain('"APP_VAR"'); + expect(content).not.toContain('_SAGE_CONNECTOR_SENDGRID'); + }); + test('shell-set env overrides decrypted value (dotenv precedence)', async () => { const envVars = { OVERRIDDEN: 'from-secrets-blob' }; const { pk, secretsBlob, serviceKeyHex } = buildDeployedFixture(envVars); diff --git a/tests/sync/syncEngine.test.ts b/tests/sync/syncEngine.test.ts index abc8738..5d9fe07 100644 --- a/tests/sync/syncEngine.test.ts +++ b/tests/sync/syncEngine.test.ts @@ -150,6 +150,48 @@ describe('SyncEngine', () => { expect(result.unchanged).toHaveLength(0); }); + test('should never reconcile the reserved _SAGE_ namespace (cloud-authoritative)', () => { + const local = { + _SAGE_CONNECTOR_SENDGRID: 'local_manager_key', + _SAGE_ONLY_LOCAL: 'stray', + APP_VAR: 'app_local', + }; + const remote = { + _SAGE_CONNECTOR_SENDGRID: 'remote_manager_key', + _SAGE_ONLY_REMOTE: 'cloud', + APP_VAR: 'app_local', + }; + + const result = syncEngine.compareEnvironments(local, remote); + + // No reserved var appears in any reconciliation bucket — the cloud copy + // wins silently; it is never pushed, pulled, conflicted, or unchanged. + const names = [ + ...result.newLocal, + ...result.newRemote, + ...result.unchanged, + ...result.deleted, + ...result.deletedLocal, + ].map((v) => v.name); + const conflictNames = result.conflicts.map((c) => c.name); + const allNames = [...names, ...conflictNames]; + expect(allNames.some((n) => n.startsWith('_SAGE_'))).toBe(false); + + // Ordinary vars are still reconciled as usual. + expect(result.unchanged.map((v) => v.name)).toContain('APP_VAR'); + }); + + test('should not flag a reserved var as a local deletion even when previously synced', () => { + const local = {}; // user no longer has the reserved var locally + const remote = { _SAGE_CONNECTOR_SENDGRID: 'cloud_value' }; + const syncState = { synced_variables: ['_SAGE_CONNECTOR_SENDGRID'] } as any; + + const result = syncEngine.compareEnvironments(local, remote, undefined, undefined, syncState); + + expect(result.deletedLocal).toHaveLength(0); + expect(result.newRemote).toHaveLength(0); + }); + test('should handle complex scenario with all types', () => { const local = { ONLY_LOCAL: 'local',