Skip to content
Closed
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
4 changes: 4 additions & 0 deletions src/commands/editCommand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`;

Expand Down Expand Up @@ -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];
Expand Down
25 changes: 23 additions & 2 deletions src/commands/runCommand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string | undefined>,
): Record<string, string | undefined> {
const out: Record<string, string | undefined> = {};
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
Expand Down Expand Up @@ -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<string, string | undefined>): Promise<number> {
// 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));
Expand All @@ -44,7 +65,7 @@ function spawnChild(args: string[], env: Record<string, string | undefined>): 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<string, string>,
env: childEnv as Record<string, string>,
stdio: 'inherit',
shell: process.platform === 'win32',
});
Expand Down Expand Up @@ -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.
}
Expand Down
20 changes: 20 additions & 0 deletions src/core/reservedNamespace.ts
Original file line number Diff line number Diff line change
@@ -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);
}
7 changes: 7 additions & 0 deletions src/sync/syncEngine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
KeepVariableEntry,
SyncState,
} from '../types/index';
import { isReservedNamespace } from '../core/reservedNamespace';

export class SyncEngine {
/**
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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
Expand Down
41 changes: 41 additions & 0 deletions tests/commands/runCommand.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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);
Expand Down
42 changes: 42 additions & 0 deletions tests/sync/syncEngine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Loading