Skip to content
Open
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
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -484,6 +484,20 @@ probes from `scripts/code_quality.py`.
3.12 syntax and cost a full session).
- Nine real `.env` keys were reported as typos by `atlas config validate`.

### Proxy reports its workspace path

- New `GET /workspace` endpoint returns the host and container paths the
proxy has mounted, so a client can check whether its own folder matches
what the proxy is actually editing — exact, instead of the VS Code
extension's old post-edit `fs.stat` heuristic. Requires the service token
like any other route, since it discloses a host filesystem path.
- `docker-compose.yml` now passes `ATLAS_PROJECT_DIR` into the proxy's own
environment (previously only used at compose-time to build the bind mount,
never reaching the container), so the endpoint has something to report.
- The VS Code extension's `alignment.ts` tries the endpoint first, falling
back to the existing `atlas workspace` CLI check when it's unreachable or
the proxy predates this route — the CLI path stays, since only it can
detect the proxy/sandbox split-bind case.

### Simplification campaign (2026-07-29 → 2026-08)

Expand Down
1 change: 1 addition & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -431,6 +431,7 @@ services:
# Pins the proxy's "where to write files" target. Must match the
# working_dir below and the right-hand side of the volume mount.
- ATLAS_WORKSPACE_DIR=/workspace
- ATLAS_PROJECT_DIR=${ATLAS_PROJECT_DIR:-.}
- ATLAS_SERVICE_TOKEN_FILE=/run/atlas-secrets/service-token
volumes:
- ${ATLAS_MODELS_DIR:-./models}:/models:ro
Expand Down
30 changes: 30 additions & 0 deletions docs/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ The main entry point. Wraps llama-server with an agent loop, grammar-constrained
| `/health` | GET | Liveness — always 200, `status` reports `"ok"` or `"degraded"` |
| `/ready` | GET | Readiness probe — 200 only when inference, lens scoring (`lens/ready`), the sandbox, and v3-service are all healthy; 503 otherwise. Use this for load-balancer / orchestrator health checks; use `/health` for informational status. |
| `/version` | GET | API version, SSE protocol version, and the full error-code set — see [Versioning and error codes](#versioning-and-error-codes) |
| `/workspace` | GET | Host and container workspace paths for the connected proxy — see [GET /workspace](#workspace-path) |

**Catch-all:** any unmatched path is proxied directly to llama-server.

Expand Down Expand Up @@ -1216,6 +1217,30 @@ OpenAPI 3.1 spec for this surface (`proxy_openapi.yaml`, parity-checked
against the registered routes in CI) plus JSON Schemas for the error and
SSE envelopes.

---

## Workspace path

`GET /workspace` returns the host and container paths the connected
proxy has mounted, so a client can detect whether its own workspace
folder matches what the proxy is actually editing:

```json
{"project_dir": "/home/anuj/atlas-proxy", "working_dir": "/workspace", "containerized": true}
```

`project_dir` is the **host** path (from `ATLAS_PROJECT_DIR`) — empty
string when the proxy can't report an absolute path (unset, or a
relative default like `.`), never a raw relative value. `working_dir` is the path the proxy writes to inside its own process
(from `ATLAS_WORKSPACE_DIR` — `/workspace` under Compose, the launch
cwd for a locally-spawned proxy). `containerized` is `true` when
`/.dockerenv` exists; `working_dir` is not a container signal, since
the local launcher sets it too.

This endpoint requires the service token like any other route — it
discloses a host filesystem path, so it is deliberately **not** in the
open-path exemption list (`/health`, `/ready`, `/version`).

## Building a non-TUI client

A minimal client needs four things (plus one header):
Expand All @@ -1231,6 +1256,11 @@ A minimal client needs four things (plus one header):
2. **Answer `permission_request` events.** In `default`/`accept-edits` mode the turn pauses on destructive tools until you **POST `/v1/permission`** with `{session_id, tool_call_id, decision:"allow"|"deny", scope:"once"|"session"}` (echo `tool_call_id` from the event). Unanswered requests deny after `ATLAS_PERMISSION_TIMEOUT_SEC` (default 600s). Unattended clients skip this by using `mode:"yolo"` or pre-approving tools via `session_allowed_tools`.
3. **POST `/cancel`** with `{session_id}` when the user wants to abort.
4. *(Optional)* **GET `/events`** in a background goroutine/thread for the global typed-envelope feed if you want a pipeline-progress sidebar.
5. *(Optional)* **GET `/workspace`** to compare the proxy's mounted
host path against your own workspace root, and warn the user if
they differ (a "wrong folder open" check). Treat a missing or
unreachable response as "can't tell" — older proxies won't have
this route yet.

The TUI ([atlas tui](CLI.md)) is a Go reference implementation — its `model.go`/`events.go` show how to handle every event type, and `panes.go` shows one approach to rendering them. Browse `tui/` in the repo for a complete worked example. The VS Code extension (`extensions/vscode/`) is a TypeScript client built exactly to this section's surface, with unit-tested SSE parsing and permission handling.

Expand Down
15 changes: 15 additions & 0 deletions docs/schemas/proxy_openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,21 @@ paths:
api_version: {type: string}
protocol_version: {type: integer}
error_codes: {type: array, items: {type: string}}
/workspace:
get:
summary: Host and container workspace paths for the connected proxy
responses:
"200":
description: Workspace info
content:
application/json:
schema:
type: object
properties:
project_dir: {type: string}
working_dir: {type: string}
containerized: {type: boolean}
"401": {$ref: "#/components/responses/Unauthorized"}
/health:
get:
summary: >
Expand Down
17 changes: 17 additions & 0 deletions extensions/vscode/src/client/atlasClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import type {
PermissionDecisionRequest,
ReadyResponse,
VersionResponse,
WorkspaceResponse,
} from './types';

export interface AtlasClientOptions {
Expand Down Expand Up @@ -223,6 +224,22 @@ export class AtlasClient {
return (await response.json()) as VersionResponse;
}

/** GET /workspace - project_dir, working_dir, containerized */
async getWorkspace(): Promise<WorkspaceResponse | null> {
try {
const response = await fetch(`${this.baseUrl}/workspace`, {
method: 'GET',
headers: this.headers(false),
});
if (!response.ok) {
return null;
}
return (await response.json()) as WorkspaceResponse;
} catch {
return null;
}
}

/**
* GET /v1/calibration/status — lens/ASA verdict for the loaded model.
* Uncached server-side (each call re-probes the lens service), so callers
Expand Down
7 changes: 7 additions & 0 deletions extensions/vscode/src/client/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,13 @@ export interface VersionResponse {
error_codes: string[];
}

/** GET /workspace body */
export interface WorkspaceResponse {
project_dir: string;
working_dir: string;
containerized: boolean;
}

/** GET /v1/calibration/status — lens + ASA compat verdict for the loaded
* model. Called once at activation and on manual refresh only: every call
* re-probes the lens service (~50–200 ms, docs/API.md). */
Expand Down
13 changes: 8 additions & 5 deletions extensions/vscode/src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import * as vscode from 'vscode';
import { ChatViewProvider, TOKEN_SECRET_KEY } from './ui/chatView';
import { DiffProvider } from './ui/diffProvider';
import { StatusBar } from './ui/statusBar';
import { checkAlignment } from './workspace/alignment';
import { checkAlignment, WorkspaceClient } from './workspace/alignment';

export function activate(context: vscode.ExtensionContext) {
const diffs = new DiffProvider();
Expand All @@ -13,10 +13,10 @@ export function activate(context: vscode.ExtensionContext) {
// Ask once at startup rather than waiting for an edit to land somewhere
// the user cannot see. Fire-and-forget: a slow or missing CLI must not
// hold up activation.
void promptIfMisaligned(context);
void promptIfMisaligned(context, () => chat.makeClient());
context.subscriptions.push(
vscode.workspace.onDidChangeWorkspaceFolders(() => {
void promptIfMisaligned(context);
void promptIfMisaligned(context, () => chat.makeClient());
}),
diffs.register(),
statusBar,
Expand Down Expand Up @@ -106,12 +106,15 @@ const ALIGN_PROMPT_DISMISSED_KEY = 'atlas.alignPromptDismissed';

/** Offer to move the proxy's bind onto the open folder. Silent when aligned,
* when the CLI is unavailable, and once the user has dismissed it. */
async function promptIfMisaligned(context: vscode.ExtensionContext): Promise<void> {
async function promptIfMisaligned(
context: vscode.ExtensionContext,
makeClient: () => Promise<WorkspaceClient>,
): Promise<void> {
const folder = vscode.workspace.workspaceFolders?.[0];
if (!folder || context.workspaceState.get<boolean>(ALIGN_PROMPT_DISMISSED_KEY, false)) {
return;
}
if ((await checkAlignment(folder.uri.fsPath)) !== 'misaligned') {
if ((await checkAlignment(folder.uri.fsPath, undefined, await makeClient())) !== 'misaligned') {
return;
}
const choice = await vscode.window.showWarningMessage(
Expand Down
45 changes: 44 additions & 1 deletion extensions/vscode/src/workspace/alignment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,18 @@
// vitest, same as mismatch.ts.

import { exec } from 'node:child_process';
import * as path from 'node:path';
import * as fs from 'node:fs';

export type AlignState = 'aligned' | 'misaligned' | 'unknown';

/** Runs a command in cwd and resolves its exit code. Rejects if it cannot spawn. */
export type Runner = (command: string, cwd: string) => Promise<number>;

export interface WorkspaceClient {
getWorkspace(): Promise<{ project_dir: string, working_dir: string, containerized: boolean } | null>;
}

export function defaultRunner(command: string, cwd: string): Promise<number> {
return new Promise((resolve, reject) => {
const child = exec(command, { cwd, timeout: 15_000 }, (err) => {
Expand All @@ -32,9 +38,46 @@ export function defaultRunner(command: string, cwd: string): Promise<number> {
});
}

function realPathOrResolve(p: string): string {
try {
return fs.realpathSync(p);
} catch {
return path.resolve(p);
}
}

/** Port of workspace.py's _covers - true when `target` is inside `bound`
* Tries realpath first (resolves symlinks, matches Python original).
* Falls back to path.resolve if the path doesn't exist yet (e.g. test
* fixtures) - realpathSync throws on missing paths, so we can't rely on
* it alone and stay testable without a real filesystem. */
function covers(bound: string, target: string): boolean {
if (!bound) {
return false;
}
const rel = path.relative(realPathOrResolve(bound), realPathOrResolve(target));
if (path.isAbsolute(rel)) {
return false; // different roots/drives - Python's ValueError case
}
return rel === '' || !rel.startsWith('..');
}

/** 'unknown' when the CLI is missing or fails in a way we cannot interpret — a
* user without `atlas` on PATH must never be nagged about alignment. */
export async function checkAlignment(cwd: string, run: Runner = defaultRunner): Promise<AlignState> {
export async function checkAlignment(
cwd: string,
run: Runner = defaultRunner,
client?: WorkspaceClient,
): Promise<AlignState> {
if (client) {
const ws = await client.getWorkspace();
if (ws && ws.project_dir && path.isAbsolute(ws.project_dir)
&& !covers(ws.project_dir, cwd)) {
return 'misaligned'; // fast negative, no CLI needed
}
// aligned-per-proxy still needs the CLI: only it sees the sandbox bind
}

try {
const code = await run('atlas workspace', cwd);
if (code === 0) {
Expand Down
100 changes: 99 additions & 1 deletion extensions/vscode/test/alignment.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
// rounds of manual `atlas workspace align` to notice each time.

import { describe, expect, it, vi } from 'vitest';
import { checkAlignment } from '../src/workspace/alignment';
import { checkAlignment, WorkspaceClient } from '../src/workspace/alignment';

describe('checkAlignment', () => {
it('maps the `atlas workspace` exit code to a state', async () => {
Expand All @@ -30,4 +30,102 @@ describe('checkAlignment', () => {
await checkAlignment('/home/isaac/demo2', run);
expect(run).toHaveBeenCalledWith('atlas workspace', '/home/isaac/demo2');
});

describe('with an injected client', () => {
it('trusts the endpoint when misaligned, without touching the CLI', async () => {
const client: WorkspaceClient = {
getWorkspace: async () => ({
project_dir: '/home/HiIsaac/sudoku-solver',
working_dir: '/workspace',
containerized: true
})
};

const run = vi.fn(async () => 0); // would say 'aligned' if it were ever called
const result = await checkAlignment('/home/HiIsaac/tic-tac-toe', run, client);

expect(result).toBe('misaligned');
expect(run).not.toHaveBeenCalled();
});

it('endpoint unreachable, CLI fallback', async () => {
const client: WorkspaceClient = {
getWorkspace: async () => null,
};
const run = vi.fn(async () => 0);

const result = await checkAlignment('/home/HiIsaac/wanna-play', run, client);

expect(result).toBe('aligned');
expect(run).toHaveBeenCalledWith('atlas workspace', '/home/HiIsaac/wanna-play');
});

it(`is 'unknown' when both the endpoint and the CLI are unavailable`, async () => {
const client: WorkspaceClient = {
getWorkspace: async () => null
};
const run = vi.fn(() => Promise.reject(new Error('ENOENT')));
const result = await checkAlignment('/home/HiIsaac/assassins', run, client);

expect(result).toBe('unknown');
expect(run).toHaveBeenCalled();
});

it(`relative host path falls through to the CLI instead of a bogus mismatch`, async () => {
const client: WorkspaceClient = {
getWorkspace: async () => ({
project_dir: '.',
working_dir: '/workspace',
containerized: true,
}),
};
const run = vi.fn(async () => 1); // CLI says misaligned

const result = await checkAlignment('/home/HiIsaac/relative-path', run, client);

expect(result).toBe('misaligned');
expect(run).toHaveBeenCalledWith('atlas workspace', '/home/HiIsaac/relative-path');
});

// The endpoint is authoritative for 'no' only. It reports the proxy's
// own bind and knows nothing about the sandbox's, so a proxy-side
// match cannot conclude 'aligned' on its own — `atlas workspace`
// compares both binds and is the only thing that sees a SPLIT. An
// early return here would report the split-brain case as aligned,
// which is precisely the failure this module exists to catch.
it('proxy-side match still consults the CLI, which sees the sandbox bind', async () => {
const client: WorkspaceClient = {
getWorkspace: async () => ({
project_dir: '/home/HiIsaac/tic-tac-toe',
working_dir: '/workspace',
containerized: true,
}),
};
const run = vi.fn(async () => 1); // sandbox bind has drifted: SPLIT

const result = await checkAlignment('/home/HiIsaac/tic-tac-toe', run, client);

expect(result).toBe('misaligned');
expect(run).toHaveBeenCalledWith('atlas workspace', '/home/HiIsaac/tic-tac-toe');
});

it('a folder nested inside the bind is covered, not a mismatch', async () => {
// `covers` is containment, not equality — opening a subdirectory
// of the bound project is aligned, and must not fast-path to
// 'misaligned' before the CLI is asked.
const client: WorkspaceClient = {
getWorkspace: async () => ({
project_dir: '/home/HiIsaac/monorepo',
working_dir: '/workspace',
containerized: true,
}),
};
const run = vi.fn(async () => 0); // both binds agree

const result = await checkAlignment('/home/HiIsaac/monorepo/packages/api', run, client);

expect(result).toBe('aligned');
expect(run).toHaveBeenCalledWith('atlas workspace', '/home/HiIsaac/monorepo/packages/api');
});
});
});
Loading
Loading