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
5 changes: 5 additions & 0 deletions .changeset/calm-vault-origins.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@caplets/core": patch
---

Resolve Vault grant targets from config and Caplet File sources, quarantine static Caplets with unresolved Vault references instead of blocking Host startup, and accept canonical-equivalent Caplets roots.
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -46,5 +46,8 @@ brag-output/
# wrangler
.wrangler/

# Test-generated Caplets Lockfiles
# Test-generated and temp files
.caplets.lock.json
.tmp*/
*.tmp
*.temp
8 changes: 8 additions & 0 deletions .omp/lsp.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"servers": {
"typescript-language-server": {
"command": "tsc",
"args": ["--lsp", "--stdio"]
Comment thread
ian-pascoe marked this conversation as resolved.
}
}
}
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# syntax=docker/dockerfile:1.7

ARG NODE_VERSION=24-bookworm-slim
ARG PNPM_VERSION=11.9.0
ARG PNPM_VERSION=11.7.0

FROM node:${NODE_VERSION} AS build
ARG PNPM_VERSION
Expand Down
2 changes: 1 addition & 1 deletion apps/catalog/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,5 +39,5 @@
"vitest": "^4.1.10",
"wrangler": "^4.112.0"
},
"packageManager": "pnpm@11.9.0"
"packageManager": "pnpm@11.7.0"
}
4 changes: 2 additions & 2 deletions apps/dashboard/src/components/DashboardApp.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -293,8 +293,8 @@ beforeEach(() => {
toast.error.mockReset();
toast.success.mockReset();
toast.warning.mockReset();
localStorage.clear();
sessionStorage.clear();
window.localStorage.clear();
window.sessionStorage.clear();
window.history.replaceState({}, "", "/dashboard/vault");
window.matchMedia = vi.fn().mockReturnValue({
addEventListener: vi.fn(),
Expand Down
3 changes: 3 additions & 0 deletions apps/dashboard/vitest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,7 @@ export default defineConfig({
},
],
},
test: {
execArgv: ["--no-experimental-webstorage"],
},
});
2 changes: 1 addition & 1 deletion apps/docs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,5 +27,5 @@
"vite": "^8.1.5",
"vitest": "^4.1.10"
},
"packageManager": "pnpm@11.9.0"
"packageManager": "pnpm@11.7.0"
}
4 changes: 2 additions & 2 deletions mise.toml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
[tools]
bun = "1"
node = "22"
pnpm = "11.9.0"
node = "24"
pnpm = "11.7.0"
3 changes: 1 addition & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -71,8 +71,7 @@
"vitest": "^4.1.10"
},
"engines": {
"bun": ">=1",
"node": ">=22"
},
"packageManager": "pnpm@11.9.0"
"packageManager": "pnpm@11.7.0"
}
1 change: 0 additions & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,6 @@
"vitest": "^4.1.10"
},
"engines": {
"bun": ">=1",
"node": ">=22"
}
}
1 change: 0 additions & 1 deletion packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,6 @@
"vitest": "^4.1.10"
},
"engines": {
"bun": ">=1",
"node": ">=22"
}
}
30 changes: 23 additions & 7 deletions packages/core/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2407,6 +2407,8 @@ export function createProgram(io: CliIO = {}): Command {
}
return;
}
const grantOrigin = options.grant ? localVaultGrantOrigin(options.grant, env) : undefined;

const value = await readVaultValue(io);
await useConfiguredHostStorage(currentConfigPath(), async (storage) => {
const existed = (await storage.vaultValues.getStatus(name)).present;
Expand All @@ -2422,7 +2424,7 @@ export function createProgram(io: CliIO = {}): Command {
capletId: options.grant,
vaultKey: name,
referenceName: options.as ?? name,
originKind: "stored-record",
...grantOrigin!,
operator: { role: "operator", clientId: "local_cli" },
});
}
Expand Down Expand Up @@ -2577,24 +2579,24 @@ export function createProgram(io: CliIO = {}): Command {
);
return;
}
const grantOrigin = localVaultGrantOrigin(capletId, env);
await useConfiguredHostStorage(currentConfigPath(), async (storage) => {
await storage.vaultGrants.grant({
capletId,
vaultKey: name,
referenceName: options.as ?? name,
originKind: "stored-record",
...grantOrigin,
operator: { role: "operator", clientId: "local_cli" },
});
const grant = (await storage.vaultGrants.list(capletId)).find(
(candidate) =>
candidate.vaultKey === validateVaultKeyName(name) &&
candidate.referenceName === validateVaultKeyName(options.as ?? name),
candidate.referenceName === validateVaultKeyName(options.as ?? name) &&
candidate.originKind === grantOrigin.originKind &&
(candidate.originPath ?? undefined) === grantOrigin.originPath,
);
if (!grant) {
throw new CapletsError(
"INTERNAL_ERROR",
"Stored Vault access grant could not be reloaded.",
);
throw new CapletsError("INTERNAL_ERROR", "Vault access grant could not be reloaded.");
}
await storage.invalidateConfig("local_cli");
writeOut(formatVaultAccessGrant(storedVaultGrantForCli(grant), Boolean(options.json)));
Expand Down Expand Up @@ -4395,6 +4397,20 @@ function envConfigPath(
return env.CAPLETS_CONFIG?.trim() || undefined;
}

function localVaultGrantOrigin(
capletId: string,
env: NodeJS.ProcessEnv | Record<string, string | undefined>,
): { originKind: ConfigSource["kind"]; originPath?: string } {
const overlay = loadLocalOverlayConfigWithSources(
resolveConfigPath(envConfigPath(env)),
envProjectConfigPath(env),
{ vaultResolver: vaultBootstrapResolver },
);
const source = overlay.sources[capletId];
if (!source) return { originKind: "stored-record" };
return { originKind: source.kind, originPath: source.path };
}

function remoteClientForCli(
io: CliIO,
remoteUrl?: string | undefined,
Expand Down
83 changes: 73 additions & 10 deletions packages/core/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1232,6 +1232,7 @@ type ConfigInput = {
capletSets?: Record<string, unknown>;
[key: string]: unknown;
};
const quarantinedCapletIdsByInput = new WeakMap<ConfigInput, ReadonlySet<string>>();

const CAPLET_BACKEND_KEYS = [
"mcpServers",
Expand Down Expand Up @@ -1927,49 +1928,103 @@ export async function loadConfigWithHostStorage(
storage: StoredCapletSource,
path = resolveConfigPath(),
projectPath = resolveProjectConfigPath(),
options: Pick<ConfigParseOptions, "vaultResolver"> & {
options: Pick<ConfigParseOptions, "vaultResolver" | "vaultRecoveryTarget"> & {
recordCacheRoot?: string | undefined;
writeWarning?: ((warning: LocalOverlayConfigWarning) => void) | undefined;
} = {},
): Promise<ConfigWithSources> {
const recordCacheRoot = options.recordCacheRoot ?? join(defaultStateBaseDir(), "record-caplets");
await materializeStoredCaplets(storage, recordCacheRoot);
const warnings: LocalOverlayConfigWarning[] = [];
const parseOptions = {
vaultResolver: options.vaultResolver ?? defaultVaultResolver(),
vaultRecoveryTarget: options.vaultRecoveryTarget,
};
const storedCaplets = loadCapletFilesWithPaths(recordCacheRoot);
const userConfig = existsSync(path) ? readPublicConfigInput(path) : undefined;
const storedConfig = storedCaplets
? quarantineUnresolvedReferenceCaplets(
storedCaplets.config,
"stored-record",
(id) => storedCaplets.paths[id] ?? recordCacheRoot,
warnings,
parseOptions,
)
: undefined;
const userConfig = existsSync(path)
? quarantineUnresolvedReferenceCaplets(
readPublicConfigInput(path),
"global-config",
path,
warnings,
parseOptions,
)
: undefined;
const userCaplets = loadCapletFilesWithPaths(resolveCapletsRoot(path));
const userCapletsConfig = userCaplets
? quarantineUnresolvedReferenceCaplets(
userCaplets.config,
"global-file",
(id) => userCaplets.paths[id] ?? resolveCapletsRoot(path),
warnings,
parseOptions,
)
: undefined;
const projectConfig = existsSync(projectPath)
? rejectProjectConfigExecutableBackendMaps(
stripProjectServeConfig(readPublicConfigInput(projectPath), projectPath),
? quarantineUnresolvedReferenceCaplets(
rejectProjectConfigExecutableBackendMaps(
stripProjectServeConfig(readPublicConfigInput(projectPath), projectPath),
projectPath,
),
"project-config",
projectPath,
warnings,
parseOptions,
)
: undefined;
const projectCapletsRoot = resolveProjectCapletsRootForConfigPath(projectPath);
const projectCaplets = projectCapletsRoot
? loadCapletFilesWithPaths(projectCapletsRoot)
: undefined;
return buildConfigWithSources(
const projectCapletsConfig = projectCaplets
? quarantineUnresolvedReferenceCaplets(
projectCaplets.config,
Comment thread
ian-pascoe marked this conversation as resolved.
"project-file",
(id) => projectCaplets.paths[id] ?? projectCapletsRoot!,
warnings,
parseOptions,
)
: undefined;
const result = buildConfigWithSources(
[
storedCaplets
? {
input: storedCaplets.config,
input: storedConfig,
source: { kind: "stored-record", path: storedCaplets.paths },
}
: undefined,
{ input: userConfig, source: { kind: "global-config", path } },
userCaplets
? { input: userCaplets.config, source: { kind: "global-file", path: userCaplets.paths } }
? {
input: userCapletsConfig,
source: { kind: "global-file", path: userCaplets.paths },
}
: undefined,
{ input: projectConfig, source: { kind: "project-config", path: projectPath } },
projectCaplets
? {
input: projectCaplets.config,
input: projectCapletsConfig,
source: { kind: "project-file", path: projectCaplets.paths },
}
: undefined,
],
`Caplets config not found at ${path} or ${projectPath}`,
"Caplets config must define at least one MCP server, OpenAPI endpoint, Google Discovery API, GraphQL endpoint, HTTP API, CLI tools backend, or Caplet set",
options,
warnings.some((warning) => warning.recoverable)
? undefined
: "Caplets config must define at least one MCP server, OpenAPI endpoint, Google Discovery API, GraphQL endpoint, HTTP API, CLI tools backend, or Caplet set",
parseOptions,
);
for (const warning of warnings) options.writeWarning?.(warning);
return result;
}

async function materializeStoredCaplets(
Expand Down Expand Up @@ -2558,6 +2613,7 @@ function quarantineUnresolvedReferenceCaplets(
options: Pick<ConfigParseOptions, "vaultResolver" | "vaultRecoveryTarget"> = {},
): ConfigInput {
let filtered = input;
const quarantinedCapletIds = new Set<string>();

for (const backend of CAPLET_BACKEND_KEYS) {
const caplets = filtered[backend];
Expand All @@ -2582,6 +2638,7 @@ function quarantineUnresolvedReferenceCaplets(
}

filtered = removeCapletBackendId(filtered, backend, id);
quarantinedCapletIds.add(id);
for (const missing of groupMissingEnvReferences(envMissing)) {
warnings.push({
kind,
Expand All @@ -2598,6 +2655,7 @@ function quarantineUnresolvedReferenceCaplets(
}
}

quarantinedCapletIdsByInput.set(filtered, quarantinedCapletIds);
return filtered;
}

Expand Down Expand Up @@ -3243,6 +3301,11 @@ function mergeConfigInputsWithSources(...inputs: Array<ConfigInputWithSource | u
if (entry?.input === undefined) {
continue;
}
for (const id of quarantinedCapletIdsByInput.get(entry.input) ?? []) {
merged = removeCapletId(merged, id);
delete sources[id];
delete shadows[id];
}
const entryInput =
entry.source.kind === "global-config" ? entry.input : stripUserOnlyConfig(entry.input);
for (const id of capletIds(entryInput)) {
Expand Down
12 changes: 7 additions & 5 deletions packages/core/src/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,24 +186,26 @@ export class CapletsEngine {
const load = async (
path: string,
projectPath: string,
_loaderOptions?: {
loaderOptions?: {
writeWarning?: ((warning: LocalOverlayConfigWarning) => void) | undefined;
},
): Promise<CapletsConfig> =>
(
await loadConfigWithHostStorage(storage, path, projectPath, {
recordCacheRoot,
vaultResolver: await createHostStorageVaultResolver(storage),
vaultRecoveryTarget: options.vaultRecoveryTarget,
writeWarning: loaderOptions?.writeWarning,
})
Comment thread
ian-pascoe marked this conversation as resolved.
).config;
const parityConfigLoader = async (): Promise<CapletsConfig> =>
await load(configPath, join(recordCacheRoot, ".cluster-parity", "config.json"));
try {
const loaded = await loadConfigWithHostStorage(storage, configPath, projectConfigPath, {
vaultResolver: await createHostStorageVaultResolver(storage),
recordCacheRoot,
const initialConfig = await load(configPath, projectConfigPath, {
writeWarning: (warning) => {
options.writeErr?.(`Warning: ${warning.kind} at ${warning.path}: ${warning.message}\n`);
},
});
const initialConfig = loaded.config;
const parityConfig = await parityConfigLoader();
const hostRuntimeFingerprint = storage.vaultValues.hostRuntimeFingerprint(
clusterHostConfigurationFingerprint(parityConfig),
Expand Down
4 changes: 3 additions & 1 deletion packages/core/src/lockfile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -328,6 +328,7 @@ function requireSafeRelativePath(path: string, label: string): string {
}

function rejectSymlinkedExistingPath(root: string, destination: string): void {
let realRoot: string | undefined;
let current = root;
const relativePath = relative(root, destination);
for (const segment of relativePath.split(sep).filter(Boolean)) {
Expand All @@ -341,7 +342,8 @@ function rejectSymlinkedExistingPath(root: string, destination: string): void {
);
}
const real = realpathSync(current);
if (real !== root && !real.startsWith(`${root}${sep}`)) {
realRoot ??= realpathSync(root);
if (real !== realRoot && !real.startsWith(`${realRoot}${sep}`)) {
throw new CapletsError(
"CONFIG_INVALID",
`Lockfile destination ${destination} resolves outside the selected Caplets root`,
Expand Down
Loading
Loading