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
29 changes: 29 additions & 0 deletions apps/server/src/auth/EnvironmentAuth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -377,6 +377,35 @@ it.layer(NodeServices.layer)("EnvironmentAuth.layer", (it) => {
}).pipe(Effect.provide(layerEnvironmentAuth())),
);

it.effect(
"preserves a rejected MCP pairing code for exactly one concurrent lower-access retry",
() =>
Effect.gen(function* () {
const serverAuth = yield* EnvironmentAuth.EnvironmentAuth;
const pairing = yield* serverAuth.issuePairingCredential({
scopes: ["orchestration:read"],
});

const rejected = yield* Effect.flip(
serverAuth.consumeMcpApprovalCode(pairing.credential, "full-access"),
);
expect(rejected).toMatchObject({
_tag: "ServerAuthMcpApprovalCodeError",
reason: "insufficient_scope",
});

const retry = Effect.result(
serverAuth.consumeMcpApprovalCode(pairing.credential, "read-only"),
);
const results = yield* Effect.all([retry, retry], { concurrency: "unbounded" });

expect(results.filter((result) => result._tag === "Success")).toHaveLength(1);
expect(results.filter((result) => result._tag === "Failure")).toMatchObject([
{ failure: { _tag: "ServerAuthMcpApprovalCodeError", reason: "unknown_or_used" } },
]);
}).pipe(Effect.provide(layerEnvironmentAuth())),
);

it.effect("narrows seeded desktop grants to the requested scopes", () =>
Effect.gen(function* () {
const serverAuth = yield* EnvironmentAuth.EnvironmentAuth;
Expand Down
15 changes: 10 additions & 5 deletions apps/server/src/auth/EnvironmentAuth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -434,7 +434,7 @@ export class ServerAuthMcpApprovalCodeError extends Schema.TaggedError<ServerAut
) {
override get message(): string {
return this.reason === "insufficient_scope"
? "That pairing code cannot grant this access, and it is now used up. Create one with the standard scopes, or choose Read only with a new code."
? "That pairing code cannot grant this access. It has not been used, so choose an access level it grants, or create a code with the standard scopes."
: this.reason === "not_a_pairing_code"
? "That is not a one-time pairing code."
: "That pairing code is unknown, expired, or already used.";
Expand Down Expand Up @@ -920,6 +920,7 @@ export const make = Effect.gen(function* () {
readonly proofKeyThumbprint?: string;
readonly requestedScopes?: ReadonlyArray<AuthEnvironmentScope>;
readonly purpose?: "watch";
readonly requiredScopes?: ReadonlyArray<AuthEnvironmentScope>;
},
): Effect.Effect<
ResolvedBootstrapGrant,
Expand All @@ -938,6 +939,12 @@ export const make = Effect.gen(function* () {
}
return sessions.verify(credential).pipe(
mapSessionVerificationErrors,
Effect.filterOrFail(
(session) =>
input?.requiredScopes === undefined ||
input.requiredScopes.every((scope) => session.scopes.includes(scope)),
() => new ServerAuthScopeNotGrantedError({}),
),
Effect.map(
(session) =>
({
Expand Down Expand Up @@ -1415,7 +1422,7 @@ export const make = Effect.gen(function* () {
code,
access,
) =>
resolveBootstrapGrant(code.trim()).pipe(
resolveBootstrapGrant(code.trim(), { requiredScopes: mcpClientScopes(access) }).pipe(
Effect.catchTags({
ServerAuthInvalidCredentialError: () =>
Effect.fail(new ServerAuthMcpApprovalCodeError({ reason: "unknown_or_used" })),
Expand All @@ -1425,9 +1432,7 @@ export const make = Effect.gen(function* () {
Effect.flatMap((grant) =>
grant.method !== "one-time-token" && grant.method !== "reusable-dev-token"
? Effect.fail(new ServerAuthMcpApprovalCodeError({ reason: "not_a_pairing_code" }))
: mcpClientScopes(access).every((scope) => grant.scopes.includes(scope))
? Effect.void
: Effect.fail(new ServerAuthMcpApprovalCodeError({ reason: "insufficient_scope" })),
: Effect.void,
),
Effect.withSpan("EnvironmentAuth.consumeMcpApprovalCode"),
);
Expand Down
3 changes: 1 addition & 2 deletions apps/server/src/auth/McpOAuth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -457,11 +457,10 @@ it.live(
});
expect(operatorFull.status).toBe(400);

const readOnlyCode = yield* auth.issuePairingCredential({ scopes: ["orchestration:read"] });
const readOnlyApproval = yield* decide(handler, params, {
_tag: "pairing-code",
access: "read-only",
code: readOnlyCode.credential,
code: readOnly.credential,
});
expect(readOnlyApproval.status).toBe(200);
const readOnlyToken = yield* handler(
Expand Down
15 changes: 11 additions & 4 deletions apps/server/src/auth/PairingGrantStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,8 @@ export class PairingGrantStore extends Context.Service<
readonly purpose?: "watch";
readonly proofKeyThumbprint?: string;
readonly requestedScopes?: ReadonlyArray<AuthEnvironmentScope>;
/** All scopes must be granted; requestedScopes only requires an overlap for OAuth. */
readonly requiredScopes?: ReadonlyArray<AuthEnvironmentScope>;
},
) => Effect.Effect<BootstrapGrant, BootstrapCredentialError>;
}
Expand Down Expand Up @@ -525,8 +527,10 @@ const make = Effect.gen(function* () {
}

if (
input?.requestedScopes !== undefined &&
!input.requestedScopes.some((scope) => grant.scopes.includes(scope))
(input?.requestedScopes !== undefined &&
!input.requestedScopes.some((scope) => grant.scopes.includes(scope))) ||
(input?.requiredScopes !== undefined &&
!input.requiredScopes.every((scope) => grant.scopes.includes(scope)))
) {
return [
{
Expand Down Expand Up @@ -584,6 +588,7 @@ const make = Effect.gen(function* () {
...(input?.requestedScopes !== undefined
? { requestedScopes: input.requestedScopes }
: {}),
...(input?.requiredScopes !== undefined ? { requiredScopes: input.requiredScopes } : {}),
consumedAt: now,
now,
})
Expand Down Expand Up @@ -637,8 +642,10 @@ const make = Effect.gen(function* () {
}

if (
input?.requestedScopes !== undefined &&
!input.requestedScopes.some((scope) => matching.value.scopes.includes(scope))
(input?.requestedScopes !== undefined &&
!input.requestedScopes.some((scope) => matching.value.scopes.includes(scope))) ||
(input?.requiredScopes !== undefined &&
!input.requiredScopes.every((scope) => matching.value.scopes.includes(scope)))
) {
return yield* new BootstrapCredentialScopeNotGrantedError({});
}
Expand Down
17 changes: 16 additions & 1 deletion apps/server/src/persistence/AuthPairingLinks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ export const ConsumeAuthPairingLinkInput = Schema.Struct({
credential: Schema.String,
proofKeyThumbprint: Schema.NullOr(Schema.String),
requestedScopes: Schema.optionalKey(AuthEnvironmentScopes),
requiredScopes: Schema.optionalKey(AuthEnvironmentScopes),
consumedAt: Schema.DateTimeUtcFromString,
now: Schema.DateTimeUtcFromString,
});
Expand Down Expand Up @@ -161,7 +162,14 @@ export const make = Effect.gen(function* () {
const consumeAvailablePairingLinkRow = SqlSchema.findOneOption({
Request: ConsumeAuthPairingLinkInput,
Result: AuthPairingLinkRawDbRow,
execute: ({ credential, proofKeyThumbprint, requestedScopes, consumedAt, now }) =>
execute: ({
credential,
proofKeyThumbprint,
requestedScopes,
requiredScopes,
consumedAt,
now,
}) =>
sql`
UPDATE auth_pairing_links
SET consumed_at = ${consumedAt}
Expand All @@ -180,6 +188,13 @@ export const make = Effect.gen(function* () {
SELECT value FROM json_each(auth_pairing_links.scopes)
)
))
AND NOT EXISTS (
SELECT 1
FROM json_each(${JSON.stringify(requiredScopes ?? [])}) AS required
WHERE required.value NOT IN (
SELECT value FROM json_each(auth_pairing_links.scopes)
)
)
RETURNING
id AS "id",
credential AS "credential",
Expand Down
126 changes: 126 additions & 0 deletions packages/provider-opencode/src/server/adapter.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
// @effect-diagnostics-next-line nodeBuiltinImport:off - Exercise HTTP authentication with the real SDK.
import * as NodeHttp from "node:http";
import * as ChildProcess from "effect/process/ChildProcess";
import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
import * as OpenCodeServerLedger from "./OpenCodeServerLedger.ts";
import { assert, describe, it } from "@effect/vitest";
import * as NodeServices from "@effect/platform-node/NodeServices";
import type { OpencodeClient, ToolPart } from "@opencode-ai/sdk/v2";
Expand Down Expand Up @@ -50,6 +55,7 @@ import {
import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter";

const encodeUnknownJson = Schema.encodeUnknownSync(Schema.fromJsonString(Schema.Unknown));
const decodeOpenCodeSettings = Schema.decodeEffect(OpenCodeSettings);
const OPEN_CODE_TEST_SETTINGS = Schema.decodeSync(OpenCodeSettings)({
serverUrl: "http://test.invalid",
});
Expand Down Expand Up @@ -2509,3 +2515,123 @@ it.effect.each([false, true])(
Effect.scoped,
),
);

it.live.each([true, false])("opens an authenticated OpenCode session (external=%s)", (external) =>
Effect.gen(function* () {
const password = external ? "settings-password" : "environment-password";
const requests: string[] = [];
let eventConnected!: () => void;
const eventRequest = new Promise<void>((resolve) => {
eventConnected = resolve;
});
const server = yield* Effect.acquireRelease(
Effect.promise(
() =>
new Promise<ReturnType<typeof NodeHttp.createServer>>((resolve) => {
const server = NodeHttp.createServer((request, response) => {
if (
request.headers.authorization !==
`Basic ${Buffer.from(`opencode:${password}`).toString("base64")}`
) {
response.writeHead(401).end("Unauthorized");
return;
}
const url = new URL(request.url!, "http://localhost");
requests.push(url.pathname);
if (url.pathname === "/event") {
response.writeHead(200, { "Content-Type": "text/event-stream" });
response.write(": connected\n\n");
eventConnected();
return;
}
response.writeHead(200, { "Content-Type": "application/json" });
response.end(
JSON.stringify(
url.pathname.endsWith("/children")
? []
: url.pathname === "/session"
? { id: "native-auth", time: { created: 1, updated: 1 } }
: { healthy: true, version: "1.14.19" },
),
);
});
server.listen(0, "127.0.0.1", () => resolve(server));
}),
),
(server) =>
Effect.promise(
() =>
new Promise<void>((resolve) => {
server.closeAllConnections();
server.close(() => resolve());
}),
),
);
const address = server.address();
assert.isNotNull(address);
if (!address || typeof address === "string") return;
const url = `http://127.0.0.1:${address.port}`;
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
let managedStarts = 0;
const runtimeLayer = OpenCodeRuntime.layer.pipe(
Layer.provide(
Layer.succeed(
ChildProcessSpawner.ChildProcessSpawner,
ChildProcessSpawner.make((command) => {
assert.isTrue(ChildProcess.isStandardCommand(command));
if (!ChildProcess.isStandardCommand(command)) return Effect.die("Unexpected pipeline");
assert.include(command.args, "serve");
assert.equal(command.options.env?.OPENCODE_SERVER_PASSWORD, password);
managedStarts += 1;
return spawner.spawn(
ChildProcess.make(
process.execPath,
[
"-e",
`console.log("server listening on " + process.argv[1]); setInterval(() => {}, 1000)`,
url,
],
command.options,
),
);
}),
),
),
Layer.provide(OpenCodeServerLedger.layerTest),
Layer.provideMerge(NodeServices.layer),
);
const instanceId = ProviderInstanceId.make("opencode-auth");
const adapter = yield* makeOpenCodeAdapterV2({
instanceId,
settings: yield* decodeOpenCodeSettings({
serverUrl: external ? url : "",
...(external ? { serverPassword: password } : {}),
}),
environment: { OPENCODE_SERVER_PASSWORD: "environment-password" },
}).pipe(Effect.provide(runtimeLayer));
const session = yield* adapter.openSession({
threadId: ThreadId.make("thread-opencode-auth"),
providerSessionId: ProviderSessionId.make("session-opencode-auth"),
modelSelection: { instanceId, model: "anthropic/claude-sonnet", options: [] },
runtimePolicy: { runtimeMode: "full-access", interactionMode: "default", cwd: null },
});
yield* session.ensureThread({
threadId: ThreadId.make("thread-opencode-auth"),
modelSelection: { instanceId, model: "anthropic/claude-sonnet", options: [] },
runtimePolicy: { runtimeMode: "full-access", interactionMode: "default", cwd: null },
});
assert.include(requests, "/session");
yield* Effect.promise(() => eventRequest);
assert.include(requests, "/event");
assert.include(requests, "/global/health");
assert.equal(managedStarts, external ? 0 : 1);
}).pipe(
Effect.scoped,
Effect.provide([
NodeServices.layer,
TestProviderHost.layer().pipe(Layer.provide(NodeServices.layer)),
IdAllocator.layer,
McpProviderSessions.layer,
]),
),
);
7 changes: 5 additions & 2 deletions packages/provider-opencode/src/server/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -927,13 +927,16 @@ export const makeOpenCodeAdapterV2 = Effect.fn("makeOpenCodeAdapterV2")(function
binaryPath: options.settings.binaryPath,
directory: cwd,
serverUrl: options.settings.serverUrl,
...(options.settings.serverPassword
? { serverPassword: options.settings.serverPassword }
: {}),
environment: options.environment,
});
const client = runtime.createOpenCodeSdkClient({
baseUrl: connection.url,
directory: cwd,
...(connection.external && options.settings.serverPassword
? { serverPassword: options.settings.serverPassword }
...(connection.serverPassword !== undefined
? { serverPassword: connection.serverPassword }
: {}),
});

Expand Down
Loading