Skip to content

Commit 3b6eede

Browse files
authored
feat(sdk): ensure sdk clients are not global, change open() to return document handle (#2497)
* fix(sdk): prevent cross-document contamination across multi-client sessions * chore: additional docs fixes
1 parent 5959c5f commit 3b6eede

42 files changed

Lines changed: 1719 additions & 415 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/cli/scripts/export-sdk-contract.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,22 @@ import type { CliOnlyOperation } from '../src/cli/types';
3131
import { CLI_ONLY_OPERATION_DEFINITIONS } from '../src/cli/cli-only-operation-definitions';
3232
import { HOST_PROTOCOL_VERSION, HOST_PROTOCOL_FEATURES, HOST_PROTOCOL_NOTIFICATIONS } from '../src/host/protocol';
3333

34+
// ---------------------------------------------------------------------------
35+
// SDK surface classification
36+
// ---------------------------------------------------------------------------
37+
38+
type SdkSurface = 'client' | 'document' | 'internal';
39+
40+
const CLIENT_OPERATIONS = new Set(['doc.open', 'doc.describe', 'doc.describeCommand']);
41+
const INTERNAL_OPERATIONS = new Set(['doc.status']);
42+
43+
function classifySdkSurface(operationId: string): SdkSurface {
44+
if (CLIENT_OPERATIONS.has(operationId)) return 'client';
45+
if (INTERNAL_OPERATIONS.has(operationId)) return 'internal';
46+
if (operationId.startsWith('doc.session.')) return 'internal';
47+
return 'document';
48+
}
49+
3450
// ---------------------------------------------------------------------------
3551
// Paths
3652
// ---------------------------------------------------------------------------
@@ -81,6 +97,7 @@ function buildSdkContract() {
8197
// Base fields shared by all operations
8298
const entry: Record<string, unknown> = {
8399
operationId: cliOpId,
100+
sdkSurface: classifySdkSurface(cliOpId),
84101
command: metadata.command,
85102
commandTokens: [...cliCommandTokens(cliOpId)],
86103
category: cliCategory(cliOpId),

apps/cli/src/commands/close.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,6 @@ export async function runClose(tokens: string[], context: CommandContext): Promi
3131
'close',
3232
async ({ metadata, paths }) => {
3333
const effectiveMetadata = metadata;
34-
const activeSessionId = await getActiveSessionId();
35-
const wasDefaultSession = activeSessionId === effectiveMetadata.contextId;
3634

3735
if (effectiveMetadata.dirty && !mode.discard) {
3836
throw new CliError(
@@ -44,6 +42,15 @@ export async function runClose(tokens: string[], context: CommandContext): Promi
4442
);
4543
}
4644

45+
// Only read/clear the project-global active-session pointer in oneshot
46+
// (CLI) mode. Host mode must never touch this file — it causes
47+
// cross-document contamination between SDK clients.
48+
let wasDefaultSession = false;
49+
if (context.executionMode !== 'host') {
50+
const activeSessionId = await getActiveSessionId();
51+
wasDefaultSession = activeSessionId === effectiveMetadata.contextId;
52+
}
53+
4754
const result = {
4855
command: 'close',
4956
data: {
@@ -74,5 +81,6 @@ export async function runClose(tokens: string[], context: CommandContext): Promi
7481
return result;
7582
},
7683
context.sessionId,
84+
context.executionMode,
7785
);
7886
}

apps/cli/src/commands/open.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -178,7 +178,7 @@ export async function runOpen(tokens: string[], context: CommandContext): Promis
178178
const bootstrap = 'bootstrap' in opened ? opened.bootstrap : undefined;
179179
let adoptedToHostPool = false;
180180
try {
181-
const output = await exportToPath(opened.editor, paths.workingDocPath, true);
181+
await exportToPath(opened.editor, paths.workingDocPath, true);
182182
const sourcePath =
183183
opened.meta.source === 'path' && opened.meta.path
184184
? resolveSourcePathForMetadata(opened.meta.path)
@@ -195,7 +195,13 @@ export async function runOpen(tokens: string[], context: CommandContext): Promis
195195
});
196196

197197
await writeContextMetadata(paths, metadata);
198-
await setActiveSessionId(metadata.contextId);
198+
199+
// Only update the project-global active-session pointer in oneshot (CLI) mode.
200+
// Host mode must never write this file — it causes cross-document contamination
201+
// when multiple SDK clients share the same project root.
202+
if (context.executionMode !== 'host') {
203+
await setActiveSessionId(metadata.contextId);
204+
}
199205

200206
if (context.executionMode === 'host' && context.sessionPool) {
201207
context.sessionPool.adoptFromOpen(sessionId, opened, {

apps/cli/src/commands/save.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,5 +140,6 @@ export async function runSave(tokens: string[], context: CommandContext): Promis
140140
};
141141
},
142142
context.sessionId,
143+
context.executionMode,
143144
);
144145
}

apps/cli/src/lib/context.ts

Lines changed: 33 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { CliError } from './errors';
88
import { asRecord, pathExists } from './guards';
99
import type { CollaborationProfile } from './collaboration';
1010
import { validateSessionId } from './session';
11-
import type { CliIO, UserIdentity } from './types';
11+
import type { CliIO, ExecutionMode, UserIdentity } from './types';
1212

1313
const CONTEXT_VERSION = 'v1';
1414
const ACTIVE_SESSION_FILENAME = 'active-session';
@@ -538,16 +538,44 @@ export async function clearContext(paths: ContextPaths): Promise<void> {
538538
await rm(paths.contextDir, { recursive: true, force: true });
539539
}
540540

541+
/**
542+
* Resolve the target session id for an operation, respecting execution mode.
543+
*
544+
* - If an explicit session id is provided, return it immediately.
545+
* - In host mode, an explicit session id is **required** — never fall back to
546+
* the project-global active-session file. This prevents cross-document
547+
* contamination between SDK clients sharing the same project root.
548+
* - In oneshot (CLI) mode, fall back to the active-session file as a
549+
* convenience for single-terminal workflows.
550+
*/
551+
export async function resolveSessionId(
552+
sessionId: string | undefined,
553+
executionMode: ExecutionMode | undefined,
554+
): Promise<string> {
555+
if (sessionId) return sessionId;
556+
557+
if (executionMode === 'host') {
558+
throw new CliError(
559+
'SESSION_REQUIRED',
560+
'Host-mode operations require an explicit session id. Use the SDK document handle or pass --session.',
561+
);
562+
}
563+
564+
const activeSessionId = await getActiveSessionId();
565+
if (!activeSessionId) {
566+
throw new CliError('NO_ACTIVE_DOCUMENT', 'No active document. Run "superdoc open <doc>" first.');
567+
}
568+
return activeSessionId;
569+
}
570+
541571
export async function withActiveContext<T>(
542572
io: CliIO,
543573
command: string,
544574
action: (state: { metadata: ContextMetadata; paths: ContextPaths }) => Promise<T>,
545575
contextId?: string,
576+
executionMode?: ExecutionMode,
546577
): Promise<T> {
547-
const resolvedContextId = contextId ?? (await getActiveSessionId());
548-
if (!resolvedContextId) {
549-
throw new CliError('NO_ACTIVE_DOCUMENT', 'No active document. Run "superdoc open <doc>" first.');
550-
}
578+
const resolvedContextId = await resolveSessionId(contextId, executionMode);
551579

552580
return withContextLock(
553581
io,

apps/cli/src/lib/errors.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ export type CliErrorCode =
22
| 'INVALID_ARGUMENT'
33
| 'SESSION_ID_INVALID'
44
| 'SESSION_NOT_FOUND'
5+
| 'SESSION_REQUIRED'
56
| 'UNKNOWN_COMMAND'
67
| 'VALIDATION_ERROR'
78
| 'MISSING_REQUIRED'

apps/cli/src/lib/introspection-dispatch.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -226,7 +226,9 @@ const INTROSPECTION_INVOKERS: Partial<Record<CliOperationId, IntrospectionInvoke
226226
},
227227

228228
'doc.status': async (_input, context) => {
229-
const activeSessionId = await getActiveSessionId();
229+
// In host mode, do not read or report the project-global active session id.
230+
// It is a CLI-only convenience and has no meaning in host/SDK execution.
231+
const activeSessionId = context.executionMode === 'host' ? null : await getActiveSessionId();
230232

231233
try {
232234
return await withActiveContext(
@@ -273,9 +275,10 @@ const INTROSPECTION_INVOKERS: Partial<Record<CliOperationId, IntrospectionInvoke
273275
};
274276
},
275277
context.sessionId,
278+
context.executionMode,
276279
);
277280
} catch (error) {
278-
if (error instanceof CliError && error.code === 'NO_ACTIVE_DOCUMENT') {
281+
if (error instanceof CliError && (error.code === 'NO_ACTIVE_DOCUMENT' || error.code === 'SESSION_REQUIRED')) {
279282
return {
280283
command: 'status',
281284
data: {

apps/cli/src/lib/mutation-orchestrator.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -284,5 +284,6 @@ export async function executeMutationOperation(request: DocOperationRequest): Pr
284284
}
285285
},
286286
context.sessionId,
287+
context.executionMode,
287288
);
288289
}

apps/cli/src/lib/operation-executor.ts

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { getActiveSessionId } from './context';
1+
import { resolveSessionId } from './context';
22
import { CliError } from './errors';
33
import { isRecord } from './guards';
44
import { hasNonEmptyString } from './input-readers';
@@ -172,10 +172,9 @@ async function preflightCallContext(
172172
return;
173173
}
174174

175-
const activeSessionId = await getActiveSessionId();
176-
if (!hasNonEmptyString(activeSessionId)) {
177-
throw new CliError('NO_ACTIVE_DOCUMENT', `call: ${operationId} requires an active session or input.sessionId.`);
178-
}
175+
// Delegates to the centralized resolver: host mode hard-fails without an
176+
// explicit session id; oneshot mode falls back to the active-session file.
177+
await resolveSessionId(undefined, context.executionMode);
179178
}
180179

181180
// ---------------------------------------------------------------------------

apps/cli/src/lib/read-orchestrator.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,5 +169,6 @@ export async function executeReadOperation(request: DocOperationRequest): Promis
169169
}
170170
},
171171
context.sessionId,
172+
context.executionMode,
172173
);
173174
}

0 commit comments

Comments
 (0)