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
28 changes: 26 additions & 2 deletions apps/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,15 @@
// Spec: docs/DEVELOPMENT_PLAN.md §5 / §5a
// M2: onboarding + REPL + slash commands + settings + permissions matcher.

import { CredentialsStore, VERSION, diagnoseSettings, redact } from '@deepcode/core';
import { runAppServer } from '@deepcode/app-server';
import {
CredentialsStore,
VERSION,
diagnoseSettings,
fileContractWarnings,
loadFileContract,
redact,
} from '@deepcode/core';
import { capabilitiesFor, runAppServer } from '@deepcode/app-server';
import { homedir } from 'node:os';
import { resolve } from 'node:path';
import { runDiagnosticsCommand } from './diagnostics-cmd.js';
Expand Down Expand Up @@ -267,6 +274,23 @@ async function doctor(): Promise<number> {
process.stdout.write(`Configuration error: ${(error as Error).message}\n`);
failed = true;
}
// What this runtime may actually do, from the same builder the app-server
// uses — so `doctor` cannot describe a posture the runtime does not have.
try {
const home = process.env.DEEPCODE_HOME ?? resolve(homedir(), '.deepcode');
const caps = await capabilitiesFor(cwd, home);
process.stdout.write(`Sandbox: ${caps.sandbox.mode}\n`);
process.stdout.write(`Write scope: ${caps.writeScope.join(', ') || '(nothing writable)'}\n`);
process.stdout.write(`File contract: ${caps.permissions.fileContract}\n`);
process.stdout.write(`Always confirmed: ${caps.confirmationRequired.join(', ')}\n`);
process.stdout.write(`Ledger: ${caps.ledger.enabled ? caps.ledger.path : 'disabled'}\n`);
const contract = await loadFileContract({ cwd, directory: home });
for (const warning of fileContractWarnings({ ...contract, sandboxMode: caps.sandbox.mode })) {
process.stdout.write(`Warning: ${warning}\n`);
}
} catch (error) {
process.stdout.write(`Capabilities error: ${(error as Error).message}\n`);
}
return failed ? 1 : 0;
}

Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/lib/protocol-agent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ class FakeTransport implements ProtocolTransport {
reviewActions: true,
reasoningDeltas: true,
threadManagement: true,
runtimeCapabilities: true,
configDiagnostics: true,
diagnosticExport: true,
workspaceDiff: true,
Expand Down
1 change: 1 addition & 0 deletions apps/lsp/src/handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ const capabilities: InitializeResult = {
reviewActions: true,
reasoningDeltas: true,
threadManagement: true,
runtimeCapabilities: true,
configDiagnostics: true,
diagnosticExport: true,
workspaceDiff: true,
Expand Down
125 changes: 125 additions & 0 deletions apps/server/src/capabilities.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import { RuntimeHost } from '@deepcode/core';
import { ToolRegistry } from '@deepcode/core';
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import type { Provider, ProviderResult } from '@deepcode/core';
import { capabilitiesFor } from './capabilities.js';

const nullProvider: Provider = {
name: 'null',
async runTurn(): Promise<ProviderResult> {
throw new Error('not used');
},
};

/**
* The alignment plan's P0 is that permissions and tool execution are not a
* unified runtime capability — different hosts resolve the same settings
* differently. This suite is the executable form of that claim: if the CLI and
* the app-server ever disagree about what the runtime may do, it fails here
* rather than in someone's workspace.
*
* VS Code and the LSP are thin protocol clients over the app-server, so they
* receive the server's answer verbatim and are equal by construction.
*/
describe('runtime capabilities agree across hosts', () => {
let cwd: string;
let home: string;

beforeEach(async () => {
cwd = await mkdtemp(join(tmpdir(), 'dc-caps-cwd-'));
home = await mkdtemp(join(tmpdir(), 'dc-caps-home-'));
});
afterEach(async () => {
await rm(cwd, { recursive: true, force: true });
await rm(home, { recursive: true, force: true });
});

async function writeSettings(settings: Record<string, unknown>): Promise<void> {
await mkdir(join(home), { recursive: true });
await writeFile(join(home, 'settings.json'), JSON.stringify(settings), 'utf8');
}

/** The CLI path: RuntimeHost resolves policy itself. */
async function cliCapabilities(settings: {
mode?: string;
permissions?: Record<string, unknown>;
sandbox?: Record<string, unknown>;
}) {
const host = new RuntimeHost({
provider: nullProvider,
tools: new ToolRegistry([]),
cwd,
home,
mode: (settings.mode ?? 'default') as never,
permissions: settings.permissions as never,
sandboxConfig: settings.sandbox as never,
});
return host.capabilities(cwd);
}

it('agree on a default configuration', async () => {
await writeSettings({});
const server = await capabilitiesFor(cwd, home);
const cli = await cliCapabilities({});
expect(server.sandbox).toEqual(cli.sandbox);
expect(server.writeScope).toEqual(cli.writeScope);
expect(server.confirmationRequired).toEqual(cli.confirmationRequired);
expect(server.permissions.fileContract).toEqual(cli.permissions.fileContract);
});

it('agree that the sandbox is off when settings disable it', async () => {
const sandbox = { mode: 'danger-full-access' };
await writeSettings({ sandbox });
const server = await capabilitiesFor(cwd, home);
const cli = await cliCapabilities({ sandbox });
expect(server.sandbox).toEqual({ mode: 'danger-full-access', effective: false });
expect(server.sandbox).toEqual(cli.sandbox);
expect(server.writeScope).toEqual(cli.writeScope);
});

it('agree on rule counts', async () => {
const permissions = { allow: ['Read', 'Grep'], deny: ['Bash'] };
await writeSettings({ permissions });
const server = await capabilitiesFor(cwd, home);
const cli = await cliCapabilities({ permissions });
expect(server.permissions.ruleCounts).toEqual({ allow: 2, ask: 0, deny: 1 });
expect(server.permissions.ruleCounts).toEqual(cli.permissions.ruleCounts);
});

it('agree that a contract is loaded', async () => {
await writeSettings({});
await mkdir(join(cwd, '.deepcode'), { recursive: true });
await writeFile(
join(cwd, '.deepcode', 'file-contract.yaml'),
'version: 1\nrules:\n - glob: "**/.env*"\n read: deny\n',
);
const server = await capabilitiesFor(cwd, home);
const cli = await cliCapabilities({});
expect(server.permissions.fileContract).toBe('loaded');
expect(server.permissions.fileContract).toBe(cli.permissions.fileContract);
});

it('agree that a malformed contract is invalid, not absent', async () => {
await writeSettings({});
await mkdir(join(cwd, '.deepcode'), { recursive: true });
await writeFile(
join(cwd, '.deepcode', 'file-contract.yaml'),
'version: 1\nrules:\n - glob: "a"\n read: maybe\n',
);
const server = await capabilitiesFor(cwd, home);
const cli = await cliCapabilities({});
expect(server.permissions.fileContract).toBe('invalid');
expect(server.permissions.fileContract).toBe(cli.permissions.fileContract);
});

it('point at the same ledger file', async () => {
await writeSettings({});
const server = await capabilitiesFor(cwd, home);
const cli = await cliCapabilities({});
expect(server.ledger.path).toBe(cli.ledger.path);
expect(server.ledger.enabled).toBe(true);
});
});
44 changes: 44 additions & 0 deletions apps/server/src/capabilities.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
// Server-side answer to `runtime/capabilities`.
// Plan: docs/FLOATBOAT_ADOPTION_PLAN.md §2.C
//
// Resolves the same inputs the CLI resolves and hands them to the same builder
// in `@deepcode/core`. Shaping the object here instead would be the exact drift
// this method exists to make visible.

import {
buildRuntimeCapabilities,
ledgerPath,
loadFileContract,
loadSettings,
withAdditionalWritableDirs,
type Mode,
} from '@deepcode/core';
import type { RuntimeCapabilitiesResult } from '@deepcode/protocol';

export async function capabilitiesFor(
cwd: string,
home: string,
): Promise<RuntimeCapabilitiesResult> {
const { merged } = await loadSettings({ cwd, directory: home });
const contract = await loadFileContract({ cwd, directory: home });

return buildRuntimeCapabilities({
cwd,
mode: (merged.permissions?.defaultMode ?? 'default') as Mode,
permissions: merged.permissions,
sandboxConfig: withAdditionalWritableDirs(
merged.sandbox,
merged.permissions?.additionalDirectories,
cwd,
),
sandboxDefaultMode: 'workspace-write',
fileContract: contract.status,
ledger: { enabled: true, path: ledgerPath(cwd, 'changes', home) },
modules: {
hooks: !!merged.hooks,
plugins: merged.plugins?.globalEnabled !== false,
ledger: true,
fileContract: contract.status === 'loaded',
},
});
}
1 change: 1 addition & 0 deletions apps/server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,4 @@ export * from './runtime-composition.js';
export * from './structured-logger.js';
export * from './diagnostic-export.js';
export * from './workspace-diff.js';
export { capabilitiesFor } from './capabilities.js';
2 changes: 2 additions & 0 deletions apps/server/src/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { Readable, Writable } from 'node:stream';
import type { ProtocolNotification } from '@deepcode/protocol';
import { diagnoseSettings, DirectoryTrustStore } from '@deepcode/core/config';

import { capabilitiesFor } from './capabilities.js';
import { createDefaultTurnExecutor } from './default-runtime.js';
import { AppServer, type TurnExecutor } from './server.js';
import { CanonicalThreadStore } from './store.js';
Expand Down Expand Up @@ -41,6 +42,7 @@ export async function runAppServer(options: RunAppServerOptions): Promise<void>
join(options.home, 'sessions'),
),
configDiagnostics: diagnosticsFor,
runtimeCapabilities: (cwd) => capabilitiesFor(cwd, options.home),
diagnosticExport: async (cwd) => {
await logger.flush();
return exportDiagnosticBundle({
Expand Down
8 changes: 8 additions & 0 deletions apps/server/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
reviewRevertPrompt,
type CompletedItemType,
type ConfigDiagnosticsResult,
type RuntimeCapabilitiesResult,
type DiagnosticExportResult,
type ProtocolEvent,
type ProtocolRequest,
Expand Down Expand Up @@ -66,6 +67,7 @@ export interface AppServerOptions {
onEvent?: (event: ProtocolEvent) => void;
onTrace?: (record: AppServerTraceRecord) => void;
configDiagnostics?: (cwd: string) => Promise<ConfigDiagnosticsResult>;
runtimeCapabilities?: (cwd: string) => Promise<RuntimeCapabilitiesResult>;
diagnosticExport?: (cwd: string) => Promise<DiagnosticExportResult>;
workspaceDiff?: (cwd: string) => Promise<WorkspaceDiffResult>;
}
Expand Down Expand Up @@ -125,6 +127,7 @@ export class AppServer {
newTraceId: this.newTraceId,
onEvent: options.onEvent,
configDiagnostics: options.configDiagnostics !== undefined,
runtimeCapabilities: options.runtimeCapabilities !== undefined,
diagnosticExport: options.diagnosticExport !== undefined,
workspaceDiff: options.workspaceDiff !== undefined,
reviewActions: true,
Expand Down Expand Up @@ -198,6 +201,11 @@ export class AppServer {
switch (request.method) {
case 'initialize':
return this.lifecycle.initialize();
case 'runtime/capabilities':
if (!this.options.runtimeCapabilities) {
throw new RequestValidationError('Runtime capabilities are not available');
}
return this.options.runtimeCapabilities(requiredString(request.params, 'cwd'));
case 'config/diagnostics':
if (!this.options.configDiagnostics) {
throw new RequestValidationError('Configuration diagnostics are not available');
Expand Down
1 change: 1 addition & 0 deletions apps/vscode/src/protocol-runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ class FakeClient {
reviewActions: true,
reasoningDeltas: true,
threadManagement: true,
runtimeCapabilities: true,
configDiagnostics: true,
diagnosticExport: true,
workspaceDiff: true,
Expand Down
78 changes: 63 additions & 15 deletions docs/design/app-server-v1.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,21 +33,22 @@ by expecting partial deltas to replay.

## Methods

| Method | Required parameters | Result |
| -------------------- | ------------------------------- | -------------------------------------------------- |
| `initialize` | none | version and capabilities |
| `thread/start` | `cwd` | new thread snapshot |
| `thread/read` | `threadId` | thread snapshot or null |
| `thread/resume` | `threadId` | resumable snapshot |
| `turn/start` | `threadId`, object `input` | in-progress turn snapshot |
| `turn/interrupt` | `threadId`, `turnId` | whether interruption won the state race |
| `approval/respond` | thread, turn, request, decision | whether the pending request accepted the response |
| `user-input/respond` | thread, turn, request, answer | whether the pending request accepted the response |
| `config/diagnostics` | workspace cwd | value-free layers, provenance, trust gates, issues |
| `diagnostics/export` | workspace cwd | redacted local diagnostic bundle metadata |
| `workspace/diff` | `threadId` | bounded structured workspace diff |
| `review/apply` | `threadId`, `findingIds` | permission-gated review action turn |
| `review/revert` | `threadId`, `actionId` | conflict-safe restore action turn |
| Method | Required parameters | Result |
| ---------------------- | ------------------------------- | ------------------------------------------------------------------- |
| `initialize` | none | version and capabilities |
| `thread/start` | `cwd` | new thread snapshot |
| `thread/read` | `threadId` | thread snapshot or null |
| `thread/resume` | `threadId` | resumable snapshot |
| `turn/start` | `threadId`, object `input` | in-progress turn snapshot |
| `turn/interrupt` | `threadId`, `turnId` | whether interruption won the state race |
| `approval/respond` | thread, turn, request, decision | whether the pending request accepted the response |
| `user-input/respond` | thread, turn, request, answer | whether the pending request accepted the response |
| `runtime/capabilities` | workspace cwd | write scope, always-confirmed actions, sandbox and contract posture |
| `config/diagnostics` | workspace cwd | value-free layers, provenance, trust gates, issues |
| `diagnostics/export` | workspace cwd | redacted local diagnostic bundle metadata |
| `workspace/diff` | `threadId` | bounded structured workspace diff |
| `review/apply` | `threadId`, `findingIds` | permission-gated review action turn |
| `review/revert` | `threadId`, `actionId` | conflict-safe restore action turn |

`turn/start` returns before model work finishes. The server emits transient deltas while the turn
runs, then persists new provider-history messages as completed items before emitting exactly one
Expand Down Expand Up @@ -134,3 +135,50 @@ closes stdin first so the server can interrupt and persist active turns before a

- thread listing, archive, fork, and search;
- multi-client subscriptions or active-turn attachment;

## `runtime/capabilities` vs `initialize`

Both return something called capabilities, and the distinction is load-bearing:

- `initialize` answers **which protocol methods work** — `threadResume`,
`workspaceDiff`, `reviewActions`. Flags about this server's feature set.
- `runtime/capabilities` answers **what the runtime is allowed to do to the
machine** — where it may write, which actions always stop for a human, whether
the sandbox is actually in effect, whether a file contract is loaded.

A client that wants to warn "this runtime can write anywhere" needs the second,
and no amount of feature flags substitutes for it.

Keeping them apart is also what stops the next field from landing in the wrong
one. If a field describes the server's _implementation_, it belongs in
`initialize`; if it describes the _authority_ the runtime holds, it belongs here.

```json
{
"writeScope": ["/work/repo"],
"confirmationRequired": ["ledger.rollback", "plugin.install", "contract.change", "trust.grant"],
"sandbox": { "mode": "workspace-write", "effective": true },
"permissions": {
"mode": "default",
"fileContract": "loaded",
"ruleCounts": { "allow": 2, "ask": 0, "deny": 1 }
},
"ledger": { "enabled": true, "path": "~/.deepcode/projects/-work-repo/ledger/changes.jsonl" },
"modules": { "hooks": "enabled", "plugins": "disabled" }
}
```

Two deliberate choices in that payload:

- **`writeScope` reports `["<everything: sandbox disabled>"]`** under
`danger-full-access`, not `[]`. An empty array reads as "writes nowhere",
which is the exact opposite of the truth.
- **Permission rules are reported as counts, not contents.** The rules can hold
user paths; a count answers "is anything configured" without handing them to
every client that asks.

The CLI and the app-server both build this through
`buildRuntimeCapabilities` in `@deepcode/core`, and a test in
`apps/server/src/capabilities.test.ts` asserts they agree field-for-field. VS
Code and the LSP are thin protocol clients, so they receive the server's answer
verbatim.
8 changes: 8 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -554,3 +554,11 @@ export {
type ApplyPresentation,
} from './runtime/apply-ceremony.js';
export { planRollback, type RollbackContext, type RollbackPlanResult } from './ledger/rollback.js';

// Runtime capability declaration (plan §2.C)
export {
ALWAYS_CONFIRMED_ACTIONS,
buildRuntimeCapabilities,
type BuildRuntimeCapabilitiesInput,
type RuntimeCapabilities,
} from './runtime/capabilities.js';
Loading
Loading