Skip to content

Commit 6fd5402

Browse files
committed
fix(core): isolate marketplace agent identities and runtime state
1 parent 2775bb8 commit 6fd5402

5 files changed

Lines changed: 159 additions & 79 deletions

File tree

‎packages/core/src/marketplace.ts‎

Lines changed: 66 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -3,26 +3,59 @@ export * as MarketplaceLocal from "./marketplace.js"
33
import { randomUUID } from "node:crypto"
44
import path from "node:path"
55
import { eq } from "drizzle-orm"
6-
import { Clock, Context, Effect, FileSystem, Layer, Schema } from "effect"
6+
import { Clock, Context, Effect, FileSystem, Layer, Record, Schema } from "effect"
77
import { Marketplace } from "@opencode/schema/marketplace"
88
import { Config } from "@opencode/schema/config"
9-
import { AbsolutePath } from "@opencode/schema/schema"
9+
import { Agent } from "@opencode/schema/agent"
10+
import { AbsolutePath, NonNegativeInt } from "@opencode/schema/schema"
1011
import { Hash } from "@opencode/util/hash"
1112
import { makeGlobalNode } from "@opencode/util/effect/app-node"
1213
import { filesystem } from "@opencode/util/effect/app-node-platform"
1314
import { Database } from "./database/database.js"
1415
import { MarketplaceTable } from "./marketplace/sql.js"
1516

17+
export const Key = Schema.String.check(Schema.isPattern(/^pkg_[a-f0-9]{64}$/)).pipe(
18+
Schema.brand("MarketplaceLocal.Key"),
19+
Schema.annotate({ identifier: "MarketplaceLocal.Key" }),
20+
)
21+
export type Key = typeof Key.Type
22+
23+
export interface Installed extends Schema.Schema.Type<typeof Installed> {}
24+
export const Installed = Schema.Struct({
25+
file: AbsolutePath,
26+
catalog: Marketplace.Name,
27+
item: Marketplace.Name,
28+
name: Schema.NonEmptyString,
29+
version: Schema.NonEmptyString,
30+
digest: Schema.String.check(Schema.isPattern(/^sha256:[a-f0-9]{64}$/)),
31+
agents: Marketplace.AgentItem.fields.install.fields.agents,
32+
}).annotate({ identifier: "MarketplaceLocal.Installed" })
33+
34+
export interface State extends Schema.Schema.Type<typeof State> {}
35+
export const State = Schema.Struct({
36+
revision: NonNegativeInt,
37+
installed: Schema.Record(Key, Installed),
38+
}).annotate({ identifier: "MarketplaceLocal.State" })
39+
40+
export interface Plan extends Schema.Schema.Type<typeof Plan> {}
41+
export const Plan = Schema.Struct({
42+
id: Schema.String,
43+
revision: NonNegativeInt,
44+
expires: NonNegativeInt,
45+
key: Key,
46+
installed: Installed,
47+
}).annotate({ identifier: "MarketplaceLocal.Plan" })
48+
1649
export class Failed extends Schema.TaggedError<Failed>()("Marketplace.Failed", {
17-
reason: Schema.Literals(["catalog", "item", "plan", "expired", "revision", "collision", "capacity"]),
50+
reason: Schema.Literals(["catalog", "item", "plan", "expired", "revision", "capacity"]),
1851
message: Schema.String,
1952
}) {}
2053

2154
export interface Interface {
22-
readonly read: Effect.Effect<Marketplace.State>
23-
readonly plan: (input: { file: AbsolutePath; item: string }) => Effect.Effect<Marketplace.Plan, Failed>
24-
readonly install: (id: string) => Effect.Effect<Marketplace.State, Failed>
25-
readonly uninstall: (input: { key: Marketplace.Key; revision: number }) => Effect.Effect<Marketplace.State, Failed>
55+
readonly read: Effect.Effect<State>
56+
readonly plan: (input: { file: AbsolutePath; item: string }) => Effect.Effect<Plan, Failed>
57+
readonly install: (id: string) => Effect.Effect<State, Failed>
58+
readonly uninstall: (input: { key: Key; revision: number }) => Effect.Effect<State, Failed>
2659
}
2760

2861
export class Service extends Context.Service<Service, Interface>()("@opencode/MarketplaceLocal") {}
@@ -36,10 +69,10 @@ export const node = makeGlobalNode({
3669
Effect.gen(function* () {
3770
const database = yield* Database.Service
3871
const fs = yield* FileSystem.FileSystem
39-
const plans = new Map<string, { json: string; revision: number; expires: number; key: Marketplace.Key }>()
40-
const installedJSON = Schema.fromJsonString(Marketplace.Installed)
72+
const plans = new Map<string, { json: string; revision: number; expires: number; key: Key }>()
73+
const installedJSON = Schema.fromJsonString(Installed)
4174
const decodeInstalled = Schema.decodeUnknownEffect(installedJSON)
42-
const decodeState = Schema.decodeUnknownEffect(Marketplace.State)
75+
const decodeState = Schema.decodeUnknownEffect(State)
4376
const read = database.db
4477
.select()
4578
.from(MarketplaceTable)
@@ -48,13 +81,11 @@ export const node = makeGlobalNode({
4881
.pipe(
4982
Effect.orDie,
5083
Effect.flatMap((row) =>
51-
row
52-
? decodeState(row).pipe(Effect.orDie)
53-
: Effect.succeed(Marketplace.State.make({ revision: 0, installed: {} })),
84+
row ? decodeState(row).pipe(Effect.orDie) : Effect.succeed(State.make({ revision: 0, installed: {} })),
5485
),
5586
)
56-
const write = Effect.fnUntraced(function* (next: Marketplace.State) {
57-
const encoded = yield* Schema.encodeEffect(Marketplace.State)(next).pipe(Effect.orDie)
87+
const write = Effect.fnUntraced(function* (next: State) {
88+
const encoded = yield* Schema.encodeEffect(State)(next).pipe(Effect.orDie)
5889
yield* database.db
5990
.insert(MarketplaceTable)
6091
.values({ id: 0, ...encoded })
@@ -86,7 +117,7 @@ export const node = makeGlobalNode({
86117
})(text).pipe(Effect.mapError((error) => new Failed({ reason: "catalog", message: error.message })))
87118
const item = catalog.items.find((item) => item.id === input.item)
88119
if (!item) return yield* new Failed({ reason: "item", message: `Catalog item not found: ${input.item}` })
89-
const installed: Marketplace.Installed = {
120+
const installed: Installed = {
90121
file: AbsolutePath.make(file),
91122
catalog: catalog.id,
92123
item: item.id,
@@ -96,7 +127,7 @@ export const node = makeGlobalNode({
96127
agents: item.install.agents,
97128
}
98129
const id = randomUUID()
99-
const key = Marketplace.Key.make(`pkg_${Hash.sha256(JSON.stringify([file, catalog.id, item.id]))}`)
130+
const key = Key.make(`pkg_${Hash.sha256(JSON.stringify([file, catalog.id, item.id]))}`)
100131
const state = yield* read
101132
const json = yield* Schema.encodeEffect(installedJSON)(installed).pipe(Effect.orDie)
102133
const now = yield* Clock.currentTimeMillis
@@ -138,16 +169,6 @@ export const node = makeGlobalNode({
138169
reason: "revision",
139170
message: "Marketplace state changed; prepare a new plan",
140171
})
141-
const names = Object.keys(installed.agents)
142-
if (
143-
Object.entries(state.installed).some(
144-
([key, value]) => key !== stored.key && names.some((name) => Object.hasOwn(value.agents, name)),
145-
)
146-
)
147-
return yield* new Failed({
148-
reason: "collision",
149-
message: "An installed package already owns an agent with this name",
150-
})
151172
return yield* write({
152173
revision: state.revision + 1,
153174
installed: { ...state.installed, [stored.key]: installed },
@@ -159,10 +180,7 @@ export const node = makeGlobalNode({
159180
return next
160181
})
161182

162-
const uninstall = Effect.fn("MarketplaceLocal.uninstall")(function* (input: {
163-
key: Marketplace.Key
164-
revision: number
165-
}) {
183+
const uninstall = Effect.fn("MarketplaceLocal.uninstall")(function* (input: { key: Key; revision: number }) {
166184
return yield* database.db
167185
.transaction(() =>
168186
Effect.gen(function* () {
@@ -186,9 +204,22 @@ export const node = makeGlobalNode({
186204
),
187205
})
188206

189-
/** Explicit projection only; the caller owns where these entries sit in its Location's config order. */
190-
export function documents(state: Marketplace.State) {
191-
return Object.entries(state.installed)
207+
/** Catalog-local names cannot address the qualified IDs of other packages or ordinary config agents. */
208+
export function agentID(key: Key, name: string) {
209+
return Agent.ID.make(`marketplace/${key}/${name}`)
210+
}
211+
212+
/** Explicit projection only. Agent identities are namespaced independently of config-entry order. */
213+
export function documents(state: State) {
214+
return Record.toEntries(state.installed)
192215
.toSorted(([left], [right]) => left.localeCompare(right))
193-
.map(([, item]) => new Config.Document({ type: "document", info: new Config.Info({ agents: item.agents }) }))
216+
.map(
217+
([key, item]) =>
218+
new Config.Document({
219+
type: "document",
220+
info: new Config.Info({
221+
agents: Object.fromEntries(Object.entries(item.agents).map(([name, info]) => [agentID(key, name), info])),
222+
}),
223+
}),
224+
)
194225
}
Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core"
2-
import type { Marketplace } from "@opencode/schema/marketplace"
2+
import type { MarketplaceLocal } from "../marketplace.js"
33

44
export const MarketplaceTable = sqliteTable("marketplace_registry", {
55
id: integer().primaryKey(),
66
revision: integer().notNull(),
7-
installed: text({ mode: "json" }).$type<(typeof Marketplace.State.Encoded)["installed"]>().notNull(),
7+
installed: text({ mode: "json" }).$type<(typeof MarketplaceLocal.State.Encoded)["installed"]>().notNull(),
88
})

‎packages/core/test/config/agent.test.ts‎

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { FSUtil } from "@opencode/util/fs-util"
1414
import { Global } from "@opencode/util/global"
1515
import { Permission } from "@opencode/core/permission"
1616
import { AgentPlugin } from "@opencode/core/plugin/agent"
17+
import { MarketplaceLocal } from "@opencode/core/marketplace"
1718
import { AbsolutePath } from "@opencode/core/schema"
1819
import { ConfigNormalize } from "@opencode/core/config/normalize"
1920
import { ConfigAgentV1 } from "@opencode/core/v1/config/agent"
@@ -63,6 +64,81 @@ test("keeps schema fields and name out of legacy agent options", () => {
6364
})
6465

6566
describe("ConfigAgentPlugin.Plugin", () => {
67+
it.effect("keeps Marketplace model, prompt and permissions separate from built-in and user agents", () =>
68+
Effect.gen(function* () {
69+
const agents = yield* Agent.Service
70+
yield* AgentPlugin.Plugin.effect(host({ agent: agentHost(agents) }))
71+
const build = yield* agents.get(Agent.ID.make("build"))
72+
const explore = yield* agents.get(Agent.ID.make("explore"))
73+
if (!build || !explore) throw new Error("expected built-in agents")
74+
const key = MarketplaceLocal.Key.make(`pkg_${"a".repeat(64)}`)
75+
const marketplace = decode({
76+
agents: Object.fromEntries(
77+
["build", "explore", "plan", "reviewer"].map((name) => [
78+
name,
79+
{
80+
system: `Marketplace ${name}`,
81+
model: "marketplace/model",
82+
mode: "subagent",
83+
permissions: [{ action: "edit", resource: "*", effect: "allow" }],
84+
},
85+
]),
86+
),
87+
})
88+
if (!marketplace.agents) throw new Error("expected Marketplace agents")
89+
const state = MarketplaceLocal.State.make({
90+
revision: 1,
91+
installed: {
92+
[key]: {
93+
file: AbsolutePath.make(path.resolve("marketplace.json")),
94+
catalog: "team",
95+
item: "review",
96+
name: "Review",
97+
version: "1.0.0",
98+
digest: `sha256:${"a".repeat(64)}`,
99+
agents: marketplace.agents,
100+
},
101+
},
102+
})
103+
const entries = [
104+
new Document({
105+
type: "document",
106+
info: decode({
107+
agents: {
108+
reviewer: {
109+
system: "User reviewer",
110+
model: "user/model",
111+
permissions: [{ action: "edit", resource: "*", effect: "deny" }],
112+
},
113+
},
114+
}),
115+
}),
116+
...MarketplaceLocal.documents(state),
117+
]
118+
yield* ConfigAgentPlugin.Plugin.effect(host({ agent: agentHost(agents) })).pipe(
119+
Effect.provide(Config.testLayer(entries)),
120+
)
121+
const currentBuild = yield* agents.get(Agent.ID.make("build"))
122+
const currentExplore = yield* agents.get(Agent.ID.make("explore"))
123+
expect(currentBuild?.system).toBe(build.system)
124+
expect(currentBuild?.model).toEqual(build.model)
125+
expect(currentBuild?.permissions).toEqual(build.permissions)
126+
expect(currentExplore?.system).toBe(explore.system)
127+
expect(currentExplore?.model).toEqual(explore.model)
128+
expect(currentExplore?.permissions).toEqual(explore.permissions)
129+
expect(yield* agents.get(Agent.ID.make("plan"))).toBeUndefined()
130+
const user = yield* agents.get(Agent.ID.make("reviewer"))
131+
const installed = yield* agents.get(Agent.ID.make(`marketplace/${key}/reviewer`))
132+
if (!user || !installed) throw new Error("expected separate user and Marketplace agents")
133+
expect(user.system).toBe("User reviewer")
134+
expect(user.model).toEqual(Model.Ref.parse("user/model"))
135+
expect(Permission.evaluate("edit", "file.ts", user.permissions).effect).toBe("deny")
136+
expect(installed.system).toBe("Marketplace reviewer")
137+
expect(installed.model).toEqual(Model.Ref.parse("marketplace/model"))
138+
expect(Permission.evaluate("edit", "file.ts", installed.permissions).effect).toBe("allow")
139+
}),
140+
)
141+
66142
for (const item of [
67143
{ name: "separate legacy variant", frontmatter: "model: example/chat\nvariant: high", model: "example/chat#high" },
68144
{ name: "unqualified model", frontmatter: "model: example/chat", model: "example/chat" },

‎packages/core/test/marketplace-local.test.ts‎

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import { tmpdirScoped } from "./fixture/tmpdir"
1010

1111
const it = testEffect(LayerNode.compile(MarketplaceLocal.node))
1212
const catalog = (system = "reviewed", id = "review") => ({
13-
schema: "opencode.marketplace/v1",
13+
schema: "opencode.marketplace/experimental",
1414
id: "team",
1515
name: "Team",
1616
items: [
@@ -42,7 +42,9 @@ it.live("applies the captured agent snapshot once and leaves user files untouche
4242
const state = yield* marketplace.install(plan.id)
4343
expect(state.revision).toBe(1)
4444
expect(state.installed[plan.key].agents.review.system).toBe("reviewed")
45-
expect(MarketplaceLocal.documents(state)[0]?.info.agents?.review.system).toBe("reviewed")
45+
expect(MarketplaceLocal.documents(state)[0]?.info.agents?.[`marketplace/${plan.key}/review`].system).toBe(
46+
"reviewed",
47+
)
4648
const replay = yield* Effect.flip(marketplace.install(plan.id))
4749
expect(replay.reason).toBe("plan")
4850
const removed = yield* marketplace.uninstall({ key: plan.key, revision: state.revision })
@@ -96,24 +98,27 @@ it.live("expires a plan using the caller's clock without sleeping", () =>
9698
}),
9799
)
98100

99-
it.live("rejects overlapping agent ownership and updates the same package through a new plan", () =>
101+
it.live("keeps same-named agents in distinct package namespaces across updates", () =>
100102
Effect.gen(function* () {
101103
const tmp = yield* tmpdirScoped()
102104
const file = AbsolutePath.make(path.join(tmp.path, "marketplace.json"))
103105
const marketplace = yield* MarketplaceLocal.Service
104106
yield* Effect.promise(() => Bun.write(file, JSON.stringify(catalog())))
105107
const first = yield* marketplace.plan({ file, item: "review" })
106108
yield* marketplace.install(first.id)
107-
yield* Effect.promise(() => Bun.write(file, JSON.stringify(catalog("collision", "another"))))
108-
const collision = yield* marketplace.plan({ file, item: "another" })
109-
expect((yield* Effect.flip(marketplace.install(collision.id))).reason).toBe("collision")
109+
yield* Effect.promise(() => Bun.write(file, JSON.stringify(catalog("another package", "another"))))
110+
const second = yield* marketplace.plan({ file, item: "another" })
111+
yield* marketplace.install(second.id)
110112
yield* Effect.promise(() => Bun.write(file, JSON.stringify(catalog("updated"))))
111113
const update = yield* marketplace.plan({ file, item: "review" })
112114
expect(update.key).toBe(first.key)
113115
const state = yield* marketplace.install(update.id)
114-
expect(Object.keys(state.installed)).toEqual([first.key])
116+
expect(Object.keys(state.installed).sort()).toEqual([first.key, second.key].sort())
115117
expect(state.installed[first.key].agents.review.system).toBe("updated")
116-
expect(state.revision).toBe(2)
118+
expect(state.installed[second.key].agents.review.system).toBe("another package")
119+
const projected = MarketplaceLocal.documents(state).flatMap((document) => Object.keys(document.info.agents ?? {}))
120+
expect(projected.sort()).toEqual([`marketplace/${first.key}/review`, `marketplace/${second.key}/review`].sort())
121+
expect(state.revision).toBe(3)
117122
}),
118123
)
119124

@@ -125,6 +130,7 @@ it.live("rejects unsupported catalog shapes before changing durable state", () =
125130
const valid = catalog()
126131
const invalid = [
127132
"{",
133+
JSON.stringify({ ...valid, schema: "opencode.marketplace/v1" }),
128134
JSON.stringify({ ...valid, items: [...valid.items, ...valid.items] }),
129135
JSON.stringify({ ...valid, items: [{ ...valid.items[0], kind: "plugin" }] }),
130136
JSON.stringify({

‎packages/schema/src/marketplace.ts‎

Lines changed: 1 addition & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ export * as Marketplace from "./marketplace.js"
22

33
import { Schema } from "effect"
44
import { ConfigAgent } from "./config/agent.js"
5-
import { AbsolutePath, NonNegativeInt } from "./schema.js"
65

76
export const Name = Schema.String.check(Schema.isPattern(/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/)).annotate({
87
identifier: "Marketplace.Name",
@@ -22,7 +21,7 @@ export const AgentItem = Schema.Struct({
2221

2322
export interface Catalog extends Schema.Schema.Type<typeof Catalog> {}
2423
export const Catalog = Schema.Struct({
25-
schema: Schema.Literal("opencode.marketplace/v1"),
24+
schema: Schema.Literal("opencode.marketplace/experimental"),
2625
id: Name,
2726
name: Schema.NonEmptyString,
2827
items: Schema.Array(AgentItem).check(Schema.isMaxLength(128)),
@@ -33,35 +32,3 @@ export const Catalog = Schema.Struct({
3332
}),
3433
)
3534
.annotate({ identifier: "Marketplace.Catalog" })
36-
37-
export const Key = Schema.String.check(Schema.isPattern(/^pkg_[a-f0-9]{64}$/)).pipe(
38-
Schema.brand("Marketplace.Key"),
39-
Schema.annotate({ identifier: "Marketplace.Key" }),
40-
)
41-
export type Key = typeof Key.Type
42-
43-
export interface Installed extends Schema.Schema.Type<typeof Installed> {}
44-
export const Installed = Schema.Struct({
45-
file: AbsolutePath,
46-
catalog: Name,
47-
item: Name,
48-
name: Schema.NonEmptyString,
49-
version: Schema.NonEmptyString,
50-
digest: Schema.String.check(Schema.isPattern(/^sha256:[a-f0-9]{64}$/)),
51-
agents: AgentItem.fields.install.fields.agents,
52-
}).annotate({ identifier: "Marketplace.Installed" })
53-
54-
export interface State extends Schema.Schema.Type<typeof State> {}
55-
export const State = Schema.Struct({
56-
revision: NonNegativeInt,
57-
installed: Schema.Record(Key, Installed),
58-
}).annotate({ identifier: "Marketplace.State" })
59-
60-
export interface Plan extends Schema.Schema.Type<typeof Plan> {}
61-
export const Plan = Schema.Struct({
62-
id: Schema.String,
63-
revision: NonNegativeInt,
64-
expires: NonNegativeInt,
65-
key: Key,
66-
installed: Installed,
67-
}).annotate({ identifier: "Marketplace.Plan" })

0 commit comments

Comments
 (0)