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
9 changes: 9 additions & 0 deletions README.ko.md
Original file line number Diff line number Diff line change
Expand Up @@ -303,6 +303,15 @@ codemode --account u1 --host local --code-file task.js

알 수 없는 실행 옵션이나 잘못된 선택값은 게스트 코드를 실행하기 전에 거절합니다. 선택값을 생략하면 네이티브 Aside의 기본값을 따릅니다. 반환된 라우팅 정보는 지정한 선택값이지, 실제 로그인 계정이나 원격 기기를 검증했다는 뜻은 아닙니다. 원격 호스트 이름은 브라우저 작업 때 Aside가 확인하므로, `return 1` 같은 실행만으로 해당 기기의 존재를 검증할 수는 없습니다.

공유 캐시·영구 승인·탭 저널은 설정이나 호출별 선택값에 account와 host가 모두 있어야
재사용할 수 있습니다. 빈 기본 컨텍스트를 포함해 하나라도 빠지면 재사용을 끕니다. 두 값을
지정하고 관찰·승인을 새로 받으세요. 기존의 컨텍스트 없는 기록은 재사용하지 않습니다.
브라우저 읽기는 계속 Aside 기본값을 상속할 수 있습니다. `browse.captureMany`와 `report.build`의
로컬 파일 저장에는 명시적인 `host: "local"`이 필요합니다. 호스트를 생략해도 원격 기기를
상속할 수 있어 거절합니다. 검증된 아티팩트 전송 경로는 없습니다. 모든 CLI 모드는 실행이나
설정 쓰기 전에 옵션을 검사합니다. `--enable-browse`는 `--json`만, `--install-mcp`는
`--account`, `--json`, `--force`, `--no-discovery`를 받습니다.

### 브라우징은 기본으로 켜져 있습니다

설치한 그대로 `browse`를 부를 수 있습니다. 끄고 싶은 기계는 설정에서 `browseCaps.enabled`를
Expand Down
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -330,6 +330,15 @@ codemode --account u1 --host local --code-file task.js

Unknown execution flags and malformed selectors fail before guest code runs. An omitted selector inherits the native Aside default; this is not evidence of which account or device that default currently resolves to. Returned routing metadata reports the selected context, not independently verified browser identity. Remote host names are validated by native Aside when a browser operation runs, not by a browser-free `return 1` probe.

Shared browser caches, persistent approvals and tab journals require both account and host,
from config or per-call selectors. An incomplete context, including the empty default, disables
their reuse. To use these features, specify both selectors and obtain fresh observations and
approvals; old unscoped records are not reused. Browser reads still inherit Aside defaults.
`browse.captureMany` and `report.build` require explicit `host: "local"` for local files. Omitted
hosts may inherit remote devices and are refused too, because no verified artifact transfer exists.
All CLI modes validate flags before execution or settings writes. `--enable-browse` accepts only
`--json`; `--install-mcp` accepts `--account`, `--json`, `--force`, and `--no-discovery`.

### Browsing is on by default

A fresh install can call `browse` with no extra step. A machine that would rather it could not
Expand Down
58 changes: 54 additions & 4 deletions src/browser-context.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// Browser execution routing context validation and argv generation.
// Enforces per-execution routing for CLI (--account uN, --host host) and MCP execute_code.
import { createHash } from 'node:crypto';
import path from 'node:path';

export function normalizeAccount(id) {
if (id === null || id === undefined) return null;
Expand Down Expand Up @@ -32,11 +33,12 @@ export function validateHost(host) {
if (typeof host !== 'string') {
throw new TypeError('--host must be a string, got ' + typeof host);
}
if (/[\u0000-\u001f\u007f]/.test(host)) throw new Error('--host cannot contain control characters');
const s = host.trim();
if (!s) {
throw new Error('--host cannot be empty');
}
if (s.startsWith('--')) {
if (s.startsWith('-')) {
throw new Error('--host requires a value, for example --host local');
}
// Native Aside validates host identity ('local', remote ID, or device name).
Expand Down Expand Up @@ -91,8 +93,34 @@ const OPT_WITH_VALUE = new Set([
'--host',
]);

export function parseExecutionArgv(argv = []) {
const MODE_FLAGS = {
'--enable-browse': ['--enable-browse', '--json'],
'--install-mcp': ['--install-mcp', '--json', '--force', '--no-discovery'],
'--doctor': ['--doctor', '--browse', '--json'],
};
const MODE_VALUES = {
'--enable-browse': [],
'--install-mcp': ['--account'],
'--doctor': ['--config', '--cwd', '--account', '--host'],
};

// Detect modes without interpreting opaque --code values, then validate before dispatch.
export function parseCliArgv(argv = []) {
const modes = new Set();
for (let i = 0; i < argv.length; i++) {
const token = argv[i];
if (Object.hasOwn(MODE_FLAGS, token)) modes.add(token);
if (token === '--code' || token === '--code-file') modes.add('execution');
if (OPT_WITH_VALUE.has(token)) i++;
}
if (modes.size > 1) throw new Error('choose one CLI mode: execution, --doctor, --enable-browse, or --install-mcp');
return parseExecutionArgv(argv, [...modes][0] || 'execution');
}

export function parseExecutionArgv(argv = [], mode = 'execution') {
const flags = new Map();
const booleanFlags = new Set(MODE_FLAGS[mode] || []);
const valueFlags = new Set(MODE_VALUES[mode] || OPT_WITH_VALUE);
let i = 0;

while (i < argv.length) {
Expand All @@ -106,12 +134,13 @@ export function parseExecutionArgv(argv = []) {
}

if (token.startsWith('--')) {
if (!OPT_WITH_VALUE.has(token)) {
throw new Error(`unknown execution flag: ${token}`);
if (!valueFlags.has(token) && !booleanFlags.has(token)) {
throw new Error(`unknown ${mode} flag: ${token}`);
}
if (flags.has(token)) {
throw new Error(`duplicate flag: ${token}`);
}
if (booleanFlags.has(token)) { flags.set(token, true); i++; continue; }
if (i + 1 >= argv.length) {
if (token === '--account') throw new Error('--account needs an account id, for example --account u1');
if (token === '--host') throw new Error('--host requires a value, for example --host local');
Expand All @@ -123,6 +152,7 @@ export function parseExecutionArgv(argv = []) {
throw new Error(`${token} requires a value`);
}
const val = argv[i + 1];
if (token !== '--code' && !val.trim()) throw new Error(`${token} requires a non-empty value`);
// For flags other than --code, a value starting with -- that matches known flags is a missing value error
if (token !== '--code' && val.startsWith('--') && (OPT_WITH_VALUE.has(val) || val.length > 2)) {
if (token === '--account') throw new Error('--account needs an account id, for example --account u1');
Expand All @@ -139,6 +169,8 @@ export function parseExecutionArgv(argv = []) {
}
}

if (flags.has('--code') && flags.has('--code-file')) throw new Error('pass exactly one of --code or --code-file');

if (flags.has('--account')) {
validateAccount(flags.get('--account'));
}
Expand Down Expand Up @@ -260,6 +292,7 @@ export function resolveBrowserContext({ argv, parsedFlags, mcpArgs, config } = {
}

export function buildCacheIdentity(baseAccountRoot, browserContext) {
if (!hasCompleteBrowserContext(browserContext)) return null;
const routing = routingReport(browserContext);
const tuple = [
'v1',
Expand All @@ -272,3 +305,20 @@ export function buildCacheIdentity(baseAccountRoot, browserContext) {
const hash = createHash('sha256').update(JSON.stringify(tuple)).digest('hex').slice(0, 32);
return `${baseAccountRoot}#ctx=${hash}`;
}

export function hasCompleteBrowserContext(context) {
return Boolean(context?.account && context?.host);
}

export function browserContextDirectory(base, context) {
if (!hasCompleteBrowserContext(context)) throw new Error('persistent browser state requires both account and host');
const tag = createHash('sha256').update(JSON.stringify([normalizeAccount(context.account), context.host])).digest('hex').slice(0, 16);
return path.join(base, `ctx-${tag}`);
}

export function requireLocalArtifacts(context, action) {
if (context?.host === 'local') return;
const error = new Error(`${action} requires explicit host: "local" for local artifacts; inherited or remote hosts have no verified transfer path`);
error.code = 'EREMOTEARTIFACT';
throw error;
}
5 changes: 4 additions & 1 deletion src/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import { createDetailedRgResolver, getAsideBundledRgPath } from './rg.js';
import { createHostGlobals } from './host/globals.js';
import { runCode } from './sandbox.js';
import { requireInteger, fitEnvelope } from './execution-output.js';
import { resolveBrowserContext, parseExecutionArgv, validateAccount, validateHost, routingReport } from './browser-context.js';
import { resolveBrowserContext, parseCliArgv, parseExecutionArgv, validateAccount, validateHost, routingReport } from './browser-context.js';

const argv = process.argv.slice(2);
// Mode detection must not inspect option VALUES (guest code may itself be '--doctor').
Expand Down Expand Up @@ -58,6 +58,9 @@ function fail(error, extra = {}) {
process.exit(1);
}

try { parseCliArgv(argv); }
catch (e) { fail(e.message, { code: 'EBADARGV' }); }

// Before the config is loaded, on purpose. The file this command exists to fix is one of the
// files loadConfig reads, so a broken one would block the only easy way to repair it.
if (has('--enable-browse')) {
Expand Down
32 changes: 19 additions & 13 deletions src/host/browse/browse.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,8 @@ import { createWatch, createRecipes, createPrefetch } from './watch.js';
import { createAttach } from './attach.js';
import os from 'node:os';
import path from 'node:path';
import { createHash } from 'node:crypto';
import { listAccountRoots } from '../../register.js';
import { routingReport, normalizeAccount, buildCacheIdentity } from '../../browser-context.js';
import { routingReport, buildCacheIdentity, hasCompleteBrowserContext, browserContextDirectory, requireLocalArtifacts } from '../../browser-context.js';
import { APPROVAL_DIR } from './approvals.js';
import { JOURNAL_DIR } from './tab-journal.js';

Expand All @@ -42,31 +41,28 @@ export function createBrowse({ config = {}, spawnAside, resolveAside, signal, en
// suite was writing every refusal, claim and rejection into the shared one and leaving
// them there, which is litter in somebody's temp directory and a test that can see
// another run's records.
const hasSelection = Boolean(browserContext?.account || browserContext?.host);
const contextTag = hasSelection
? createHash('sha256').update(JSON.stringify([normalizeAccount(browserContext?.account), browserContext?.host || null])).digest('hex').slice(0, 16)
: null;
const completeContext = hasCompleteBrowserContext(browserContext);
const baseApprovalDir = typeof caps.approvalDir === 'string' && caps.approvalDir ? caps.approvalDir : APPROVAL_DIR;
const approvalDir = contextTag ? path.join(baseApprovalDir, `ctx-${contextTag}`) : baseApprovalDir;
const approvalDir = completeContext ? browserContextDirectory(baseApprovalDir, browserContext) : null;

const baseJournalDir = typeof caps.tabJournalDir === 'string' && caps.tabJournalDir ? caps.tabJournalDir : JOURNAL_DIR;
const journalDir = contextTag ? path.join(baseJournalDir, `ctx-${contextTag}`) : baseJournalDir;
const journalDir = completeContext ? browserContextDirectory(baseJournalDir, browserContext) : null;

const approvals = createApprovals({
const approvals = completeContext ? createApprovals({
ttlMs: Number.isSafeInteger(caps.approvalTtlMs) ? caps.approvalTtlMs : undefined,
dir: approvalDir,
});
const tabJournal = createTabJournal({
}) : null;
const tabJournal = completeContext ? createTabJournal({
dir: journalDir,
});
}) : null;
const session = createBrowseSession({ spawnAside: spawner, resolveAside: resolver, signal, breaker, approvals, tabJournal, browserContext });
const captureManyImpl = createCaptureMany({ session, assertInside });
// Not u/0. Aside runs as whichever profile accounts.json calls current, and on a machine
// where that is id 1 a hardcoded u/0 points the cache at a profile nobody is using.
const asideHome = path.join(env.USERPROFILE || env.HOME || os.homedir() || '', '.aside');
const baseAccountRoot = resolveAccountRoot(asideHome, browserContext?.account);
const accountRoot = buildCacheIdentity(baseAccountRoot, browserContext);
const cache = createCache({ ttlMs: Number.isSafeInteger(caps.cacheTtlMs) ? caps.cacheTtlMs : undefined });
const cache = completeContext ? createCache({ ttlMs: Number.isSafeInteger(caps.cacheTtlMs) ? caps.cacheTtlMs : undefined }) : null;

async function probe() {
let resolved = null;
Expand All @@ -90,6 +86,7 @@ export function createBrowse({ config = {}, spawnAside, resolveAside, signal, en
e.code = 'EDISABLED';
throw e;
}
requireLocalArtifacts(browserContext, 'browse.captureMany');
return captureManyImpl(urls, { ...opts, browseCaps: caps });
}

Expand All @@ -110,6 +107,7 @@ export function createBrowse({ config = {}, spawnAside, resolveAside, signal, en
// user's own tabs out of this.
async function leakedTabs() {
if (caps.enabled !== true) throw disabledError();
if (!tabJournal) return { ok: false, code: 'EUNRESOLVEDCONTEXT', tabs: [], error: 'tab ownership requires both account and host; inherited identity is unverified' };
const listed = await attachImpl.tabs();
if (!listed || listed.ok !== true) {
return { ok: false, code: listed && listed.code ? listed.code : 'ENOTABS', error: 'could not read the open tabs, so nothing can be called abandoned', tabs: [] };
Expand All @@ -127,6 +125,7 @@ export function createBrowse({ config = {}, spawnAside, resolveAside, signal, en
async function approve(opts = {}) {
if (caps.enabled !== true) throw disabledError();
const id = requireApprovalId('browse.approve', opts);
if (!approvals) throw unresolvedApprovalError();
const existing = approvals.read(id);
const selected = routingReport(browserContext);
if (existing.record?.context && (existing.record.context.account !== selected.account || existing.record.context.host !== selected.host)) {
Expand Down Expand Up @@ -157,6 +156,7 @@ export function createBrowse({ config = {}, spawnAside, resolveAside, signal, en
async function reject(opts = {}) {
if (caps.enabled !== true) throw disabledError();
const id = requireApprovalId('browse.reject', opts);
if (!approvals) throw unresolvedApprovalError();
const done = approvals.reject(id);
// A claimed record is never reported as rejected. By then the steps may have run, and
// saying otherwise is the one wrong answer this surface can give.
Expand Down Expand Up @@ -192,6 +192,12 @@ export function createBrowse({ config = {}, spawnAside, resolveAside, signal, en

export { CAPABILITY_MATRIX };

function unresolvedApprovalError() {
const error = new Error('persistent approvals require both account and host; inherited identity is unverified');
error.code = 'EUNRESOLVEDCONTEXT';
return error;
}

function disabledError() {
const e = new Error(`browse is turned off on this machine. Turn it back on with: ${ENABLE_BROWSE_COMMAND}`);
e.code = 'EDISABLED';
Expand Down
3 changes: 1 addition & 2 deletions src/host/browse/session.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ import { compile, deadlineMath, WIRE_LIMIT } from './script.js';
import { attachDiff } from './diff.js';
import { helperStamp } from './helper-bundle.js';
import { DEAD_END } from './policy.js';
import { createTabJournal } from './tab-journal.js';
import { lossMarkers } from './result-contract.js';
import { browserContextToArgv, routingReport } from '../../browser-context.js';

Expand Down Expand Up @@ -357,7 +356,7 @@ export function buildRunSource(job, { plan = null, runId = null, requested = nul
return compile({ ...job, runId }, rows);
}

export function createBrowseSession({ spawnAside, resolveAside, now = Date.now, signal, breaker = null, approvals = null, tabJournal = createTabJournal(), browserContext = null } = {}) {
export function createBrowseSession({ spawnAside, resolveAside, now = Date.now, signal, breaker = null, approvals = null, tabJournal = null, browserContext = null } = {}) {
if (typeof spawnAside !== 'function') throw new TypeError('spawnAside is required');
if (typeof resolveAside !== 'function') throw new TypeError('resolveAside is required');

Expand Down
8 changes: 5 additions & 3 deletions src/host/namespaces.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,13 @@ import { createAsideSpawner } from './browse/spawn.js';
import { createReport as createReportCore } from './report/report.js';
import { createApi } from './browse/adapters.js';
import { ENABLE_BROWSE_COMMAND } from '../enable-browse.js';
import { requireLocalArtifacts } from '../browser-context.js';

export function createReport({ config = {}, signal, assertInside, env = process.env, browserContext = null } = {}) {
export function createReport({ config = {}, signal, assertInside, env = process.env, browserContext = null, spawnAside, resolveAside } = {}) {
const caps = config.browseCaps || {};
const session = createBrowseSession({
spawnAside: createAsideSpawner(),
resolveAside: createAsideResolver(config, env),
spawnAside: spawnAside || createAsideSpawner(),
resolveAside: resolveAside || createAsideResolver(config, env),
signal,
browserContext,
});
Expand All @@ -22,6 +23,7 @@ export function createReport({ config = {}, signal, assertInside, env = process.
e.code = 'EDISABLED';
throw e;
}
requireLocalArtifacts(browserContext, 'report.build');
return core.build({ ...opts, browseCaps: caps });
},
});
Expand Down
5 changes: 4 additions & 1 deletion structure/batch-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,10 @@ reports per-item facts; it never decides whether the run succeeded.
Both batch and raw native calls prepend the execution's account/host selectors before `repl`.
Returned routing metadata describes those selections, not a live identity attestation. Persistent
approvals, tab journals and browser caches are separated by routing context so selecting another
account or host cannot reuse the first context's approval or tab ownership.
account or host cannot reuse the first context's approval or tab ownership. Both selectors must be
specified, either per-call or by config. Incomplete contexts, including the empty default, disable
shared cache and persistent approvals/journals. Session construction does not create an unscoped
journal; the browse factory supplies one only for a complete context.

`itemStatus` reads codes before `ok`, because a host-killed item and an ordinary failure both carry
`ok: false` while an item whose action list half ran arrives with `ok: true`. Reading `ok` first
Expand Down
16 changes: 16 additions & 0 deletions structure/browse-surface.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,22 @@ Everything under `src/host/browse/` exists to turn a list of urls into rows with
the caller cannot account for. One module spawns Aside, one compiles the script, one validates the
job, and the rest are the checks that keep a batch honest.

## Routing state and local artifacts

The execution's `browserContext` scopes the cache, approval store and tab journal. Both account
and host must be specified, either by config or per-call. Any incomplete context, including the
empty default, disables shared cache and persistent approvals/journals: inherited identity can
change between calls, and local accounts.json cannot prove remote identity. Browser reads still
inherit Aside defaults when selectors are omitted. `browse.context()` still reports requested
routing, not a verified identity. `approve`, `reject` and `leakedTabs` report `EUNRESOLVEDCONTEXT`
when persistent identity is unavailable. Configure both selectors and obtain fresh observations
and approvals; old unscoped records are not reused.

`browse.captureMany` and `report.build` require explicit host `local` before spawning. An omitted
host can inherit a remote device even with an explicit account, so it is refused with
`EREMOTEARTIFACT` too. There is no verified transfer path for remote session files. Textual browser
operations remain available on remote hosts; their session paths never authorize local file reads.

## The job, validated before anything spawns

`schema.js` is the option source of truth. Unknown keys are rejected with the valid list, and a key
Expand Down
Loading
Loading