@naswerks/periscope 1.0.0 → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +6 -0
- package/README.md +2 -2
- package/contracts/wire-vectors/bulk_delivered.full.json +21 -0
- package/contracts/wire-vectors/bulk_delivered.minimal.json +21 -0
- package/contracts/wire-vectors/bulk_delivered.without-stat-pair.json +22 -0
- package/contracts/wire-vectors/bulk_failed.declared-reason.json +22 -0
- package/contracts/wire-vectors/bulk_failed.unknown-reason.json +23 -0
- package/contracts/wire-vectors/bulk_request.full.json +21 -0
- package/contracts/wire-vectors/host_configure.set-and-remove.json +28 -0
- package/contracts/wire-vectors/host_configure_result.applied.json +34 -0
- package/contracts/wire-vectors/host_configure_result.refused.json +33 -0
- package/contracts/wire-vectors/link_ack.cursors.json +25 -0
- package/contracts/wire-vectors/link_ack.empty.json +16 -0
- package/contracts/wire-vectors/link_bye.cause.json +16 -0
- package/contracts/wire-vectors/link_hello.empty.json +33 -0
- package/contracts/wire-vectors/link_hello.full.json +44 -0
- package/contracts/wire-vectors/link_ping.nonce.json +16 -0
- package/contracts/wire-vectors/link_ping.unknown-payload-key.json +18 -0
- package/contracts/wire-vectors/link_pong.nonce.json +16 -0
- package/contracts/wire-vectors/link_welcome.empty.json +18 -0
- package/contracts/wire-vectors/link_welcome.full.json +25 -0
- package/contracts/wire-vectors/refused.empty.json +10 -0
- package/contracts/wire-vectors/refused.encode.empty-at.json +18 -0
- package/contracts/wire-vectors/refused.encode.empty-session-id.json +18 -0
- package/contracts/wire-vectors/refused.encode.missing-member.json +22 -0
- package/contracts/wire-vectors/refused.encode.seq-zero.json +18 -0
- package/contracts/wire-vectors/refused.encode.undeclared-configure-refusal.json +34 -0
- package/contracts/wire-vectors/refused.encode.undeclared-wire-refusal.json +27 -0
- package/contracts/wire-vectors/refused.missing-member.json +10 -0
- package/contracts/wire-vectors/refused.not-json.json +10 -0
- package/contracts/wire-vectors/refused.over-limit.json +10 -0
- package/contracts/wire-vectors/refused.seq-zero.json +10 -0
- package/contracts/wire-vectors/refused.unknown-frame.json +10 -0
- package/contracts/wire-vectors/refused.unknown-kind.json +10 -0
- package/contracts/wire-vectors/repository_list.root.json +19 -0
- package/contracts/wire-vectors/repository_list.subdirectory.json +19 -0
- package/contracts/wire-vectors/repository_list_result.entries.json +34 -0
- package/contracts/wire-vectors/repository_list_result.refused.json +24 -0
- package/contracts/wire-vectors/repository_read.head.json +20 -0
- package/contracts/wire-vectors/repository_read_result.refused.json +25 -0
- package/contracts/wire-vectors/repository_read_result.truncated.json +22 -0
- package/contracts/wire-vectors/session_cancel.bare.json +17 -0
- package/contracts/wire-vectors/session_configure.full.json +22 -0
- package/contracts/wire-vectors/session_configure.minimal.json +20 -0
- package/contracts/wire-vectors/session_delta.agent-message.json +27 -0
- package/contracts/wire-vectors/session_list.request.json +18 -0
- package/contracts/wire-vectors/session_list_result.empty.json +21 -0
- package/contracts/wire-vectors/session_list_result.full.json +36 -0
- package/contracts/wire-vectors/session_new.full.json +72 -0
- package/contracts/wire-vectors/session_new.minimal.json +22 -0
- package/contracts/wire-vectors/session_new.request-all-null.json +41 -0
- package/contracts/wire-vectors/session_prompt.empty.json +18 -0
- package/contracts/wire-vectors/session_prompt.multibyte.json +18 -0
- package/contracts/wire-vectors/session_prompt.text.json +18 -0
- package/contracts/wire-vectors/session_prompt.unknown-frame-key.json +20 -0
- package/contracts/wire-vectors/session_prompt.unknown-payload-key.json +20 -0
- package/contracts/wire-vectors/session_update.agent-message.json +26 -0
- package/contracts/wire-vectors/session_update.state-transition-with-activity.json +44 -0
- package/contracts/wire-vectors/session_update.state-transition.json +41 -0
- package/contracts/wire-vectors/session_update.wire-refusal.json +26 -0
- package/contracts/wire-vectors/transcript_failed.declared-reason.json +22 -0
- package/contracts/wire-vectors/transcript_list.from-start.json +19 -0
- package/contracts/wire-vectors/transcript_list.paged.json +19 -0
- package/contracts/wire-vectors/transcript_list_result.entry-without-cwd.json +30 -0
- package/contracts/wire-vectors/transcript_list_result.full.json +29 -0
- package/contracts/wire-vectors/transcript_list_result.last-page.json +29 -0
- package/contracts/wire-vectors/transcript_tail.any-user-entry.json +22 -0
- package/contracts/wire-vectors/transcript_tail.with-needle.json +22 -0
- package/contracts/wire-vectors/transcript_tail_result.absent.json +23 -0
- package/contracts/wire-vectors/transcript_tail_result.found.json +23 -0
- package/contracts/wire-vectors/workspace_list.from-start.json +19 -0
- package/contracts/wire-vectors/workspace_list_result.page.json +48 -0
- package/contracts/wire-vectors/workspace_list_result.refused.json +26 -0
- package/contracts/wire-vectors/workspace_release.by-path-delete-branch.json +22 -0
- package/contracts/wire-vectors/workspace_release.request.json +22 -0
- package/contracts/wire-vectors/workspace_release_bulk.two-entries.json +32 -0
- package/contracts/wire-vectors/workspace_release_bulk_result.mixed.json +37 -0
- package/contracts/wire-vectors/workspace_release_result.partial.json +26 -0
- package/contracts/wire-vectors/workspace_release_result.refused.json +26 -0
- package/contracts/wire-vectors/workspace_release_result.released.json +23 -0
- package/package.json +6 -1
- package/src/bin/command.ts +198 -0
- package/src/bin/config.ts +104 -0
- package/src/bin/login.ts +149 -0
- package/src/bin/main.ts +204 -0
- package/src/bin/pair.ts +246 -0
- package/src/bin/periscope.ts +3 -0
- package/src/bin/posture.ts +232 -0
- package/src/bin/reconfigure.ts +266 -0
- package/src/bin/serve.ts +615 -0
- package/src/bin/status.ts +61 -0
- package/src/bin/workspaces.ts +280 -0
- package/src/control/backoff.ts +40 -0
- package/src/control/codec.ts +557 -0
- package/src/control/credential.ts +33 -0
- package/src/control/frames.ts +1586 -0
- package/src/control/link-state.ts +67 -0
- package/src/control/link.ts +894 -0
- package/src/control/queue.ts +248 -0
- package/src/control/seq.ts +76 -0
- package/src/control/stream-routing.ts +216 -0
- package/src/control/stream.ts +149 -0
- package/src/core/async-queue.ts +63 -0
- package/src/core/failure.ts +61 -0
- package/src/core/index.ts +22 -0
- package/src/core/keyed-turns.ts +47 -0
- package/src/core/paths.ts +84 -0
- package/src/core/refusal.ts +430 -0
- package/src/core/result.ts +27 -0
- package/src/core/time.ts +34 -0
- package/src/core/vocab.ts +44 -0
- package/src/core/workspace-id.ts +11 -0
- package/src/gate/command.ts +617 -0
- package/src/gate/decision.ts +173 -0
- package/src/gate/escalate.ts +164 -0
- package/src/gate/gate.ts +635 -0
- package/src/gate/index.ts +53 -0
- package/src/gate/jail.ts +210 -0
- package/src/gate/local.ts +115 -0
- package/src/gate/outcome.ts +164 -0
- package/src/gate/shell.ts +295 -0
- package/src/host/agent-process.ts +694 -0
- package/src/host/bulk-post.ts +166 -0
- package/src/host/claude-transcripts.ts +404 -0
- package/src/host/config-file.ts +246 -0
- package/src/host/git-facts.ts +119 -0
- package/src/host/hooks.ts +102 -0
- package/src/host/host.ts +1901 -0
- package/src/host/index.ts +92 -0
- package/src/host/link-state-file.ts +79 -0
- package/src/host/loopback.ts +177 -0
- package/src/host/machine.ts +27 -0
- package/src/host/mcp-server.ts +66 -0
- package/src/host/package-facts.ts +25 -0
- package/src/host/paired-credential-store.ts +140 -0
- package/src/host/paths.ts +175 -0
- package/src/host/repository-read.ts +245 -0
- package/src/host/session-store.ts +133 -0
- package/src/host/sign-in.ts +352 -0
- package/src/host/telemetry.ts +105 -0
- package/src/host/token-cache.ts +214 -0
- package/src/host/transcript-fs.ts +110 -0
- package/src/host/wire-request.ts +285 -0
- package/src/host/workspace-fs.ts +90 -0
- package/src/host/workspace-trust.ts +92 -0
- package/src/identity/authorize.ts +127 -0
- package/src/identity/config.ts +227 -0
- package/src/identity/credential.ts +176 -0
- package/src/identity/device-code.ts +217 -0
- package/src/identity/index.ts +84 -0
- package/src/identity/mode.ts +161 -0
- package/src/identity/paired-credential.ts +89 -0
- package/src/identity/pkce.ts +113 -0
- package/src/identity/store.ts +98 -0
- package/src/identity/token.ts +203 -0
- package/src/index.ts +140 -0
- package/src/mcp/descriptor.ts +75 -0
- package/src/mcp/index.ts +23 -0
- package/src/mcp/schema.ts +214 -0
- package/src/mcp/server.ts +180 -0
- package/src/persistence/entry.ts +142 -0
- package/src/persistence/index.ts +47 -0
- package/src/persistence/key.ts +77 -0
- package/src/persistence/mirror.ts +118 -0
- package/src/persistence/receipt.ts +182 -0
- package/src/persistence/retention.ts +118 -0
- package/src/persistence/store.ts +183 -0
- package/src/persistence/transition-log.ts +176 -0
- package/src/protocol.ts +215 -0
- package/src/sessions/index.ts +27 -0
- package/src/sessions/registry.ts +392 -0
- package/src/sessions/session.ts +440 -0
- package/src/sessions/spawn-env.ts +318 -0
- package/src/state/coverage.ts +360 -0
- package/src/state/index.ts +58 -0
- package/src/state/machine.ts +359 -0
- package/src/state/model.ts +417 -0
- package/src/state/observer.ts +499 -0
- package/src/state/reporter.ts +60 -0
- package/src/state/store.ts +157 -0
- package/src/telemetry/index.ts +10 -0
- package/src/telemetry/usage.ts +117 -0
- package/src/workspace/git-worktree.ts +551 -0
- package/src/workspace/index.ts +17 -0
- package/src/workspace/plain-dir.ts +132 -0
- package/src/workspace/provider.ts +159 -0
- package/src/workspace/worktree-porcelain.ts +118 -0
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The local JSONL mirror: the store's effects, on a real disk.
|
|
3
|
+
*
|
|
4
|
+
* It lives here for the one reason everything else in this directory does: it names `node:fs`.
|
|
5
|
+
* The rules worth testing (which key becomes which record, what a missing transcript returns, what
|
|
6
|
+
* a corrupt line does) are in `persistence/store.ts` and are proven without a disk. This file is
|
|
7
|
+
* the part that cannot be, so it is small and it is the only place a path is built.
|
|
8
|
+
*
|
|
9
|
+
* The root is a jail, and the token is escaped before it gets here. A project key is
|
|
10
|
+
* caller-supplied and may contain anything, separators included; `transcriptToken` percent-encodes
|
|
11
|
+
* each segment so a key cannot introduce one. This checks containment anyway, because a jail that
|
|
12
|
+
* relies on its caller having escaped correctly is a jail with one lock on the outside.
|
|
13
|
+
*/
|
|
14
|
+
import { mkdir, readFile, readdir, rm, stat, appendFile } from 'node:fs/promises';
|
|
15
|
+
import { dirname, join, resolve } from 'node:path';
|
|
16
|
+
|
|
17
|
+
import type { StoreEffects } from '../persistence/store.js';
|
|
18
|
+
import { isContainedBy, normalizePath, requireAbsolute } from '../core/paths.js';
|
|
19
|
+
|
|
20
|
+
/** File-backed effects rooted at one directory. The root must be absolute. */
|
|
21
|
+
export function nodeStoreEffects(root: string): StoreEffects {
|
|
22
|
+
const absolute = requireAbsolute(root);
|
|
23
|
+
if (!absolute.ok) {
|
|
24
|
+
throw new Error(`the transcript root must be absolute: ${absolute.refusal.detail}`);
|
|
25
|
+
}
|
|
26
|
+
const rootPath = normalizePath(absolute.value);
|
|
27
|
+
|
|
28
|
+
const pathFor = (token: string): string => {
|
|
29
|
+
const candidate = normalizePath(resolve(rootPath, `${token}.jsonl`));
|
|
30
|
+
if (!isContainedBy(candidate, rootPath)) {
|
|
31
|
+
// Unreachable through `transcriptToken`, which escapes every segment. Kept because the day it
|
|
32
|
+
// becomes reachable is the day somebody writes a second token function.
|
|
33
|
+
throw new Error(`refusing a transcript path outside the store root: ${token}`);
|
|
34
|
+
}
|
|
35
|
+
return candidate;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
return {
|
|
39
|
+
async appendTo(token: string, text: string): Promise<void> {
|
|
40
|
+
const file = pathFor(token);
|
|
41
|
+
await mkdir(dirname(file), { recursive: true });
|
|
42
|
+
// A real append, not a read-modify-write: a transcript reaches megabytes and batches arrive
|
|
43
|
+
// throughout a turn, so rewriting per batch would cost the square of the session's length.
|
|
44
|
+
await appendFile(file, text, 'utf8');
|
|
45
|
+
},
|
|
46
|
+
|
|
47
|
+
async readAll(token: string): Promise<string | null> {
|
|
48
|
+
try {
|
|
49
|
+
return await readFile(pathFor(token), 'utf8');
|
|
50
|
+
} catch (error) {
|
|
51
|
+
// A transcript that was never written is `null`, and that is distinct from a read that
|
|
52
|
+
// failed — the store contract turns the first into "nothing was ever stored" and must not
|
|
53
|
+
// be handed it for a permission error.
|
|
54
|
+
if (isMissing(error)) return null;
|
|
55
|
+
throw error;
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
|
|
59
|
+
async list(projectKey: string) {
|
|
60
|
+
const scope = pathFor(encodeURIComponent(projectKey)).replace(/\.jsonl$/, '');
|
|
61
|
+
let names: string[];
|
|
62
|
+
try {
|
|
63
|
+
names = await readdir(scope);
|
|
64
|
+
} catch (error) {
|
|
65
|
+
if (isMissing(error)) return [];
|
|
66
|
+
throw error;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const found: { token: string; sessionId: string; mtime: number }[] = [];
|
|
70
|
+
for (const name of names) {
|
|
71
|
+
if (!name.endsWith('.jsonl')) continue;
|
|
72
|
+
const encodedId = name.slice(0, -'.jsonl'.length);
|
|
73
|
+
const stats = await stat(join(scope, name));
|
|
74
|
+
found.push({
|
|
75
|
+
token: `${encodeURIComponent(projectKey)}/${encodedId}`,
|
|
76
|
+
sessionId: decodeURIComponent(encodedId),
|
|
77
|
+
// Floored: the contract asks for integer epoch milliseconds and a fractional source here
|
|
78
|
+
// would not compare equal to a summary's own stamp.
|
|
79
|
+
mtime: Math.floor(stats.mtimeMs),
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
return found;
|
|
83
|
+
},
|
|
84
|
+
|
|
85
|
+
async remove(token: string): Promise<void> {
|
|
86
|
+
await rm(pathFor(token), { force: true });
|
|
87
|
+
},
|
|
88
|
+
|
|
89
|
+
async subkeys(projectKey: string, sessionId: string): Promise<readonly string[]> {
|
|
90
|
+
// A session's subagent transcripts live under a directory named for the session, beside its
|
|
91
|
+
// own file. Absent means it never spawned one, which is not an error.
|
|
92
|
+
const scope = pathFor(`${encodeURIComponent(projectKey)}/${encodeURIComponent(sessionId)}`).replace(
|
|
93
|
+
/\.jsonl$/,
|
|
94
|
+
'',
|
|
95
|
+
);
|
|
96
|
+
try {
|
|
97
|
+
return (await readdir(scope))
|
|
98
|
+
.filter((name) => name.endsWith('.jsonl'))
|
|
99
|
+
.map((name) => decodeURIComponent(name.slice(0, -'.jsonl'.length)));
|
|
100
|
+
} catch (error) {
|
|
101
|
+
if (isMissing(error)) return [];
|
|
102
|
+
throw error;
|
|
103
|
+
}
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function isMissing(error: unknown): boolean {
|
|
109
|
+
return typeof error === 'object' && error !== null && (error as { code?: string }).code === 'ENOENT';
|
|
110
|
+
}
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one place a controller's `session_new.request` becomes a local `SessionRequest`.
|
|
3
|
+
*
|
|
4
|
+
* Why it is a file and not four lines in the dispatcher: everything crossing here arrived from
|
|
5
|
+
* off-box and was validated only as JSON. The codec's job is to keep malformed bytes out, and it
|
|
6
|
+
* says so; an empty `cwd`, an empty `text` and a non-ISO `at` all pass it. So a value that is
|
|
7
|
+
* well-formed JSON and meaningless to the SDK reaches this line, and there are exactly two things to
|
|
8
|
+
* do with one: refuse it by name before a process exists, or hand it to `query()` and let the
|
|
9
|
+
* failure arrive later wearing a spawn error's clothes. This file is the first of those.
|
|
10
|
+
*
|
|
11
|
+
* What it narrows and what it deliberately does not, because the split is a judgement worth
|
|
12
|
+
* stating rather than a gap. It narrows every value with a closed set this package can see (the
|
|
13
|
+
* three setting sources, the plugin type, the thinking discriminator) because an unrecognised value
|
|
14
|
+
* there silently does nothing, which is the failure mode with no symptom. It does not re-validate
|
|
15
|
+
* the MCP server configs: that union is large, versioned with the SDK, and re-stating it here would
|
|
16
|
+
* be a second copy that can disagree with the first. Those are shape-checked and handed on, and the
|
|
17
|
+
* SDK is their validator.
|
|
18
|
+
*
|
|
19
|
+
* Nothing here can produce `sessionStore`, `sessionStoreFlush`, `spawn`, `onStderr` or `hooks`.
|
|
20
|
+
* They have no JSON form, so the wire type has no member for them and this file has nothing to read.
|
|
21
|
+
* That is the boundary, and it is structural rather than a check somebody remembered to write.
|
|
22
|
+
*/
|
|
23
|
+
import type { JsonObject, SessionNewRequest } from '../control/frames.js';
|
|
24
|
+
import type { Result } from '../core/result.js';
|
|
25
|
+
import { ok, refuse } from '../core/result.js';
|
|
26
|
+
import type { SessionRequest } from '../sessions/registry.js';
|
|
27
|
+
import type { SpawnEnvPolicy } from '../sessions/spawn-env.js';
|
|
28
|
+
import type {
|
|
29
|
+
McpServerConfig,
|
|
30
|
+
PermissionMode,
|
|
31
|
+
SdkPluginConfig,
|
|
32
|
+
SettingSource,
|
|
33
|
+
ThinkingConfig,
|
|
34
|
+
} from './agent-process.js';
|
|
35
|
+
import type { SessionConfigure } from '../control/frames.js';
|
|
36
|
+
|
|
37
|
+
/** What a controller may ask for, minus the two the composer supplies itself. */
|
|
38
|
+
export type ComposableRequest = Omit<SessionRequest, 'cwd' | 'hooks'>;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The three tiers the SDK understands, as data.
|
|
42
|
+
*
|
|
43
|
+
* Restated here rather than derived because the SDK ships them as a bare string union with no
|
|
44
|
+
* runtime value to read — so this is the one place a copy is unavoidable. `wire-request.test.ts`
|
|
45
|
+
* asserts each member against the shipped `sdk.d.ts`, the same way the permission pin does, so the
|
|
46
|
+
* copy cannot quietly drift from what it copies.
|
|
47
|
+
*/
|
|
48
|
+
export const SETTING_SOURCES: readonly SettingSource[] = ['user', 'project', 'local'];
|
|
49
|
+
|
|
50
|
+
/** The thinking shapes the SDK declares, by discriminator. Same argument as above. */
|
|
51
|
+
export const THINKING_TYPES: readonly string[] = ['adaptive', 'enabled', 'disabled'];
|
|
52
|
+
|
|
53
|
+
/** The SDK's effort levels, by name. An unknown level is refused, never dropped. */
|
|
54
|
+
export const EFFORT_LEVELS: readonly string[] = ['low', 'medium', 'high', 'xhigh', 'max'];
|
|
55
|
+
|
|
56
|
+
/** The SDK's permission modes, by name (protocol v6, CLI parity). */
|
|
57
|
+
export const PERMISSION_MODES: readonly string[] = [
|
|
58
|
+
'default',
|
|
59
|
+
'acceptEdits',
|
|
60
|
+
'bypassPermissions',
|
|
61
|
+
'plan',
|
|
62
|
+
'dontAsk',
|
|
63
|
+
'auto',
|
|
64
|
+
];
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The one mode under which a granting gate and loaded operator settings are NOT two authorities: the
|
|
68
|
+
* mode already allows everything the grant would, and a hook deny survives every mode. Named here, in
|
|
69
|
+
* the one module that holds the vocabulary, so nothing else has to spell the mode.
|
|
70
|
+
*/
|
|
71
|
+
export function isBypassMode(mode: string | null | undefined): boolean {
|
|
72
|
+
return mode === PERMISSION_MODES[2];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** What a `session_configure` frame asked for, narrowed to the SDK's own types. */
|
|
76
|
+
export interface SessionConfigureChange {
|
|
77
|
+
readonly model?: string | null;
|
|
78
|
+
readonly permissionMode?: PermissionMode;
|
|
79
|
+
readonly thinking?: ThinkingConfig;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Narrow a `session_configure` payload. Same discipline as `readSessionRequest`: a value the SDK does
|
|
84
|
+
* not declare is REFUSED by name — a mode nobody recognises must never become "the default" silently.
|
|
85
|
+
*/
|
|
86
|
+
export function readSessionConfigure(payload: SessionConfigure): Result<SessionConfigureChange> {
|
|
87
|
+
const change: { model?: string | null; permissionMode?: PermissionMode; thinking?: ThinkingConfig } = {};
|
|
88
|
+
if (payload.model !== null) change.model = payload.model;
|
|
89
|
+
if (payload.permissionMode !== null) {
|
|
90
|
+
if (!PERMISSION_MODES.includes(payload.permissionMode)) {
|
|
91
|
+
return refuse<SessionConfigureChange>(
|
|
92
|
+
'frame-malformed',
|
|
93
|
+
`permissionMode is ${JSON.stringify(payload.permissionMode)}; the modes this SDK declares are ` +
|
|
94
|
+
`${PERMISSION_MODES.join(', ')}`,
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
change.permissionMode = payload.permissionMode as PermissionMode;
|
|
98
|
+
}
|
|
99
|
+
if (payload.thinking !== null) {
|
|
100
|
+
const type = payload.thinking['type'];
|
|
101
|
+
if (typeof type !== 'string' || !THINKING_TYPES.includes(type)) {
|
|
102
|
+
return refuse<SessionConfigureChange>(
|
|
103
|
+
'frame-malformed',
|
|
104
|
+
`thinking.type is ${JSON.stringify(type)}; the shapes this SDK declares are ${THINKING_TYPES.join(', ')}`,
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
change.thinking = payload.thinking as unknown as ThinkingConfig;
|
|
108
|
+
}
|
|
109
|
+
return ok(change);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Turn a controller's request into a local one, or refuse it by name.
|
|
114
|
+
*
|
|
115
|
+
* `null` in means "every default", which is byte-for-byte the behaviour before this payload grew —
|
|
116
|
+
* so a controller that sends nothing new is unaffected by any of this.
|
|
117
|
+
*/
|
|
118
|
+
export function readSessionRequest(request: SessionNewRequest | null): Result<ComposableRequest> {
|
|
119
|
+
if (request === null) return ok({});
|
|
120
|
+
|
|
121
|
+
const composed: Record<string, unknown> = {};
|
|
122
|
+
|
|
123
|
+
if (request.resume !== null) composed['resume'] = request.resume;
|
|
124
|
+
if (request.fork !== null) composed['fork'] = request.fork;
|
|
125
|
+
if (request.includePartialMessages !== null) {
|
|
126
|
+
composed['includePartialMessages'] = request.includePartialMessages;
|
|
127
|
+
}
|
|
128
|
+
if (request.forwardSubagentText !== null) composed['forwardSubagentText'] = request.forwardSubagentText;
|
|
129
|
+
if (request.strictMcpConfig !== null) composed['strictMcpConfig'] = request.strictMcpConfig;
|
|
130
|
+
if (request.model !== null) composed['model'] = request.model;
|
|
131
|
+
// Handed on as the SDK's own option type. A string, a list of strings and a preset object are all
|
|
132
|
+
// legal, so there is no closed set to check and inventing one would refuse valid requests.
|
|
133
|
+
if (request.systemPrompt !== null) composed['systemPrompt'] = request.systemPrompt;
|
|
134
|
+
|
|
135
|
+
if (request.settingSources !== null) {
|
|
136
|
+
// A tier nobody declared is refused, not dropped. Dropping it would start a session the
|
|
137
|
+
// controller believes loads its project settings, silently without them — and the permission
|
|
138
|
+
// rules it expected to be in force would simply not be. Nothing would say so.
|
|
139
|
+
const unknown = request.settingSources.filter(
|
|
140
|
+
(source) => !(SETTING_SOURCES as readonly string[]).includes(source),
|
|
141
|
+
);
|
|
142
|
+
if (unknown.length > 0) {
|
|
143
|
+
return refuse<ComposableRequest>(
|
|
144
|
+
'frame-malformed',
|
|
145
|
+
`settingSources names ${unknown.map((source) => `"${source}"`).join(', ')}, which this host ` +
|
|
146
|
+
`does not know — the tiers are ${SETTING_SOURCES.join(', ')}. Refused rather than ignored: a ` +
|
|
147
|
+
`session started without a tier its controller asked for runs with permission rules nobody ` +
|
|
148
|
+
`told it were absent`,
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
composed['settingSources'] = [...request.settingSources] as SettingSource[];
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (request.plugins !== null) {
|
|
155
|
+
const plugins: SdkPluginConfig[] = [];
|
|
156
|
+
for (const plugin of request.plugins) {
|
|
157
|
+
// Same argument as the tiers: the SDK supports exactly one plugin type today, and a value it
|
|
158
|
+
// does not recognise loads nothing while looking like it asked for something.
|
|
159
|
+
if (plugin.type !== 'local') {
|
|
160
|
+
return refuse<ComposableRequest>(
|
|
161
|
+
'frame-malformed',
|
|
162
|
+
`a plugin declares type "${plugin.type}"; this host passes only "local" plugins to the agent`,
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
plugins.push({
|
|
166
|
+
type: 'local',
|
|
167
|
+
path: plugin.path,
|
|
168
|
+
...(plugin.skipMcpDiscovery === null ? {} : { skipMcpDiscovery: plugin.skipMcpDiscovery }),
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
composed['plugins'] = plugins;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (request.thinking !== null) {
|
|
175
|
+
const type = request.thinking['type'];
|
|
176
|
+
if (typeof type !== 'string' || !THINKING_TYPES.includes(type)) {
|
|
177
|
+
return refuse<ComposableRequest>(
|
|
178
|
+
'frame-malformed',
|
|
179
|
+
`thinking.type is ${JSON.stringify(type)}; the shapes this SDK declares are ` +
|
|
180
|
+
`${THINKING_TYPES.join(', ')}`,
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
composed['thinking'] = request.thinking;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (request.effort !== null) {
|
|
187
|
+
if (!EFFORT_LEVELS.includes(request.effort)) {
|
|
188
|
+
return refuse<ComposableRequest>(
|
|
189
|
+
'frame-malformed',
|
|
190
|
+
`effort is ${JSON.stringify(request.effort)}; the levels this SDK declares are ${EFFORT_LEVELS.join(', ')}`,
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
composed['effort'] = request.effort;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (request.permissionMode !== null) {
|
|
197
|
+
// The lane that was closed, opened by name (protocol v6). Narrowed like every other member: an
|
|
198
|
+
// unknown mode is refused, never dropped and never defaulted.
|
|
199
|
+
if (!PERMISSION_MODES.includes(request.permissionMode)) {
|
|
200
|
+
return refuse<ComposableRequest>(
|
|
201
|
+
'frame-malformed',
|
|
202
|
+
`permissionMode is ${JSON.stringify(request.permissionMode)}; the modes this SDK declares are ` +
|
|
203
|
+
`${PERMISSION_MODES.join(', ')}`,
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
composed['permissionMode'] = request.permissionMode;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (request.env !== null) {
|
|
210
|
+
const policy: SpawnEnvPolicy = {
|
|
211
|
+
...(request.env.extraAllowedKeys === null
|
|
212
|
+
? {}
|
|
213
|
+
: { extraAllowedKeys: [...request.env.extraAllowedKeys] }),
|
|
214
|
+
...(request.env.extraDeniedKeys === null ? {} : { extraDeniedKeys: [...request.env.extraDeniedKeys] }),
|
|
215
|
+
...(request.env.extraEnv === null ? {} : { extraEnv: { ...request.env.extraEnv } }),
|
|
216
|
+
};
|
|
217
|
+
composed['env'] = policy;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (request.mcpServers !== null) {
|
|
221
|
+
const servers = readMcpServers(request.mcpServers);
|
|
222
|
+
if (!servers.ok) return refuse<ComposableRequest>(servers.refusal.reason, servers.refusal.detail);
|
|
223
|
+
composed['mcpServers'] = servers.value;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
return ok(composed as ComposableRequest);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Shape-check the server map. Each value must be an object; beyond that the SDK is the validator.
|
|
231
|
+
*
|
|
232
|
+
* See this file's header for why the config union is not re-stated here.
|
|
233
|
+
*/
|
|
234
|
+
function readMcpServers(servers: JsonObject): Result<Record<string, McpServerConfig>> {
|
|
235
|
+
const read: Record<string, McpServerConfig> = {};
|
|
236
|
+
for (const [name, config] of Object.entries(servers)) {
|
|
237
|
+
if (name === '') {
|
|
238
|
+
return refuse<Record<string, McpServerConfig>>(
|
|
239
|
+
'mcp-descriptor-invalid',
|
|
240
|
+
'an MCP server was declared under an empty name; a tool reaches the model as ' +
|
|
241
|
+
'`mcp__{server}__{tool}` and an unnamed server has no reachable tools',
|
|
242
|
+
);
|
|
243
|
+
}
|
|
244
|
+
if (typeof config !== 'object' || config === null || Array.isArray(config)) {
|
|
245
|
+
return refuse<Record<string, McpServerConfig>>(
|
|
246
|
+
'mcp-descriptor-invalid',
|
|
247
|
+
`MCP server "${name}" is declared as ${config === null ? 'null' : typeof config}, not an object`,
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
read[name] = config as unknown as McpServerConfig;
|
|
251
|
+
}
|
|
252
|
+
return ok(read);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Merge the controller's servers with the ones this host registers itself.
|
|
257
|
+
*
|
|
258
|
+
* A colliding name is refused rather than resolved, and either precedence would have been wrong.
|
|
259
|
+
* Letting the controller win lets a remote peer replace the host's OWN tool server — the one that
|
|
260
|
+
* carries the host's identity into every call — with something it supplies. Letting the host win
|
|
261
|
+
* silently discards a registration the controller believes it made, and its tools then simply do not
|
|
262
|
+
* exist, with nothing anywhere saying why. So the pair is refused, before any process exists, naming
|
|
263
|
+
* the server both sides claimed.
|
|
264
|
+
*/
|
|
265
|
+
export function mergeMcpServers(
|
|
266
|
+
fromController: Readonly<Record<string, McpServerConfig>> | undefined,
|
|
267
|
+
fromHost: Readonly<Record<string, McpServerConfig>> | null,
|
|
268
|
+
): Result<Readonly<Record<string, McpServerConfig>> | null> {
|
|
269
|
+
if (fromHost === null) return ok(fromController ?? null);
|
|
270
|
+
if (fromController === undefined) return ok(fromHost);
|
|
271
|
+
|
|
272
|
+
const collisions = Object.keys(fromHost).filter((name) =>
|
|
273
|
+
Object.prototype.hasOwnProperty.call(fromController, name),
|
|
274
|
+
);
|
|
275
|
+
if (collisions.length > 0) {
|
|
276
|
+
return refuse<Readonly<Record<string, McpServerConfig>> | null>(
|
|
277
|
+
'mcp-descriptor-invalid',
|
|
278
|
+
`MCP server ${collisions.map((name) => `"${name}"`).join(', ')} is declared by both this host ` +
|
|
279
|
+
`and the controller. Refused rather than resolved: letting the controller win replaces this ` +
|
|
280
|
+
`host's own tool server, and letting the host win discards a registration the controller ` +
|
|
281
|
+
`believes it made. Rename one`,
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
return ok({ ...fromHost, ...fromController });
|
|
285
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The real effects the workspace providers run on: the filesystem, and `git`.
|
|
3
|
+
*
|
|
4
|
+
* Why they live here and the providers do not: `src/host/` is the only directory allowed to touch
|
|
5
|
+
* the machine, pinned by pins/host-boundary.test.ts, and a provider is mostly DECISIONS: which argv
|
|
6
|
+
* a git call gets, whether a directory is reused or made, what a release does. Splitting them puts
|
|
7
|
+
* the decisions where they can be tested with no disk and no repository — which is what turns "never
|
|
8
|
+
* hard-reset an existing branch" into a unit test instead of a rule only a lost branch could prove.
|
|
9
|
+
*
|
|
10
|
+
* `execFile`, never `exec`. `exec` runs its argument through a shell, so every branch name and
|
|
11
|
+
* path in it becomes an injection site — and branch names arrive from a caller this package does not
|
|
12
|
+
* control. `execFile` takes an argv array and spawns the program directly, with no shell to quote
|
|
13
|
+
* for and nothing to escape.
|
|
14
|
+
*/
|
|
15
|
+
import { execFile } from 'node:child_process';
|
|
16
|
+
import { mkdir, readdir, rm, stat } from 'node:fs/promises';
|
|
17
|
+
|
|
18
|
+
import type { CommandEffects, WorkspaceEffects } from '../workspace/provider.js';
|
|
19
|
+
|
|
20
|
+
/** The filesystem half, on `node:fs/promises`. */
|
|
21
|
+
export const nodeWorkspaceEffects: WorkspaceEffects = {
|
|
22
|
+
async makeDirectory(path: string): Promise<void> {
|
|
23
|
+
await mkdir(path, { recursive: true });
|
|
24
|
+
},
|
|
25
|
+
async exists(path: string): Promise<boolean> {
|
|
26
|
+
try {
|
|
27
|
+
await stat(path);
|
|
28
|
+
return true;
|
|
29
|
+
} catch {
|
|
30
|
+
// Any failure to stat is treated as absent. The caller's next act either creates it — which
|
|
31
|
+
// reports its own error — or attaches to it, so a stat that failed for a different reason
|
|
32
|
+
// surfaces as a named provisioning refusal rather than being swallowed here.
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
async removeDirectory(path: string): Promise<void> {
|
|
37
|
+
await rm(path, { recursive: true, force: true });
|
|
38
|
+
},
|
|
39
|
+
async listDirectories(path: string): Promise<string[]> {
|
|
40
|
+
try {
|
|
41
|
+
const entries = await readdir(path, { withFileTypes: true });
|
|
42
|
+
return entries
|
|
43
|
+
.filter((entry) => entry.isDirectory())
|
|
44
|
+
.map((entry) => entry.name)
|
|
45
|
+
.sort();
|
|
46
|
+
} catch (error) {
|
|
47
|
+
// A root that does not exist yet has no workspaces in it; anything else is the caller's refusal.
|
|
48
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return [];
|
|
49
|
+
throw error;
|
|
50
|
+
}
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* 60 seconds. Long enough for a cold worktree creation on a loaded machine; short enough that a git
|
|
56
|
+
* call which will never return does not hold a session's provisioning open indefinitely.
|
|
57
|
+
*/
|
|
58
|
+
const DEFAULT_COMMAND_TIMEOUT_MS = 60_000;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Running a program with an argv array.
|
|
62
|
+
*
|
|
63
|
+
* It rejects on a non-zero exit, and that is load-bearing rather than conventional. The worktree
|
|
64
|
+
* provider probes for a branch by running `rev-parse --verify` and reading the FAILURE as "this
|
|
65
|
+
* branch does not exist" — so an implementation that resolved with an empty string on a non-zero
|
|
66
|
+
* exit would report every branch as existing, and the provider would attach where it should create.
|
|
67
|
+
* The safe direction, but silently wrong, and it would look like it worked.
|
|
68
|
+
*/
|
|
69
|
+
export function nodeCommandEffects(timeoutMs = DEFAULT_COMMAND_TIMEOUT_MS): CommandEffects {
|
|
70
|
+
return {
|
|
71
|
+
run(program: string, args: readonly string[], cwd: string): Promise<string> {
|
|
72
|
+
return new Promise<string>((resolve, reject) => {
|
|
73
|
+
execFile(
|
|
74
|
+
program,
|
|
75
|
+
[...args],
|
|
76
|
+
{ cwd, timeout: timeoutMs, windowsHide: true },
|
|
77
|
+
(error, stdout, stderr) => {
|
|
78
|
+
if (error !== null) {
|
|
79
|
+
// stderr carries git's own message, which is the only text that says WHY. Dropping it
|
|
80
|
+
// leaves a refusal reading "command failed" with nothing to act on.
|
|
81
|
+
reject(new Error(`${program} ${args.join(' ')} failed: ${stderr.trim() || error.message}`));
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
resolve(stdout.trim());
|
|
85
|
+
},
|
|
86
|
+
);
|
|
87
|
+
});
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether a working directory is one the agent CLI considers trusted. Read, never written.
|
|
3
|
+
*
|
|
4
|
+
* The trap this exists for: in an untrusted workspace the CLI silently voids the permission rules
|
|
5
|
+
* in `.claude/settings.json` — *"Ignoring 1 permissions.allow entry … this workspace has not been
|
|
6
|
+
* trusted"* — and that sentence is a STDERR LINE, not an error. Nothing in the SDK's type surface
|
|
7
|
+
* models trust at all. A host that provisions working directories programmatically will therefore
|
|
8
|
+
* create untrusted ones by default and never be told.
|
|
9
|
+
*
|
|
10
|
+
* Why this module reads and does not write: granting trust
|
|
11
|
+
* means writing `hasTrustDialogAccepted: true` into the user's own `~/.claude.json`, a file every
|
|
12
|
+
* running CLI rewrites, so a host that grants is racing them for user-global state it does not own.
|
|
13
|
+
* It would also remove the SYMPTOM rather than the SILENCE, and silence is the actual failure: a
|
|
14
|
+
* host that reports the condition works on a machine where granting is impossible — a read-only
|
|
15
|
+
* home, a container, another user's account — and one that grants does not.
|
|
16
|
+
*
|
|
17
|
+
* What replaces it: this package depends on no settings file (`settingSources: []` reads none, so
|
|
18
|
+
* there are no rules for an untrusted workspace to void), and where a caller opts back in, the
|
|
19
|
+
* condition is REPORTED. The permission authority is the in-process hook, which the evidence shows
|
|
20
|
+
* fires regardless of allow rules — depending on the rules instead would mean depending on the one
|
|
21
|
+
* mechanism proven to be silently voidable.
|
|
22
|
+
*/
|
|
23
|
+
import { readFileSync } from 'node:fs';
|
|
24
|
+
|
|
25
|
+
import { normalizePath } from '../core/paths.js';
|
|
26
|
+
|
|
27
|
+
export type WorkspaceTrust =
|
|
28
|
+
/** The config records this directory as trusted. */
|
|
29
|
+
| 'trusted'
|
|
30
|
+
/** The config was read and this directory is either absent from it or recorded as untrusted. */
|
|
31
|
+
| 'untrusted'
|
|
32
|
+
/** No config, or one that could not be read. NOT the same as untrusted — see below. */
|
|
33
|
+
| 'unknown';
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The stderr line the CLI emits when it drops rules for this reason, as a matcher.
|
|
37
|
+
*
|
|
38
|
+
* Deliberately loose: it matches the stable part of a message whose wording the CLI owns, so a
|
|
39
|
+
* reworded prefix or a different rule count still trips it. A matcher that is too precise here fails
|
|
40
|
+
* open, which is the direction that reproduces the original silence.
|
|
41
|
+
*/
|
|
42
|
+
const UNTRUSTED_STDERR = /has not been trusted/i;
|
|
43
|
+
|
|
44
|
+
export function isUntrustedWorkspaceWarning(line: string): boolean {
|
|
45
|
+
return UNTRUSTED_STDERR.test(line);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Where the CLI keeps per-directory trust. Separate from the settings files. */
|
|
49
|
+
export function trustConfigPath(homeDir: string): string {
|
|
50
|
+
return `${normalizePath(homeDir)}/.claude.json`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Read the recorded trust for `cwd`.
|
|
55
|
+
*
|
|
56
|
+
* Comparison is normalized and case-insensitive because the recorded keys are whatever absolute
|
|
57
|
+
* path the CLI was started with — `C:\x` and `c:/x` are the same directory on Windows and would
|
|
58
|
+
* otherwise read as two.
|
|
59
|
+
*
|
|
60
|
+
* `unknown` is a third answer and not a synonym for untrusted. A config that cannot be read
|
|
61
|
+
* supports no claim, and reporting "untrusted" for it would state a fact about the user's machine
|
|
62
|
+
* that was never observed.
|
|
63
|
+
*/
|
|
64
|
+
export function readWorkspaceTrust(configPath: string, cwd: string): WorkspaceTrust {
|
|
65
|
+
let raw: string;
|
|
66
|
+
try {
|
|
67
|
+
raw = readFileSync(configPath, 'utf8');
|
|
68
|
+
} catch {
|
|
69
|
+
return 'unknown';
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
let parsed: unknown;
|
|
73
|
+
try {
|
|
74
|
+
parsed = JSON.parse(raw);
|
|
75
|
+
} catch {
|
|
76
|
+
return 'unknown';
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const projects = (parsed as { projects?: unknown }).projects;
|
|
80
|
+
if (typeof projects !== 'object' || projects === null) return 'unknown';
|
|
81
|
+
|
|
82
|
+
const wanted = normalizePath(cwd).toLowerCase();
|
|
83
|
+
for (const [recordedPath, entry] of Object.entries(projects as Record<string, unknown>)) {
|
|
84
|
+
if (normalizePath(recordedPath).toLowerCase() !== wanted) continue;
|
|
85
|
+
const accepted = (entry as { hasTrustDialogAccepted?: unknown }).hasTrustDialogAccepted;
|
|
86
|
+
return accepted === true ? 'trusted' : 'untrusted';
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// The file exists and lists projects; this one is simply not among them. That IS an observation:
|
|
90
|
+
// a directory the CLI has never been trusted in.
|
|
91
|
+
return 'untrusted';
|
|
92
|
+
}
|