Skip to content
Merged
Show file tree
Hide file tree
Changes from 36 commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
af2a833
fix(daemon): surface AMR OpenCode stall context (#6040)
Siri-Ray Jul 29, 2026
b6b23b7
test(e2e): parallelize restoration group (#6169)
PerishCode Jul 29, 2026
7f5abbd
feat(landing-page): sharpen the Codex agent page for "codex ui" inten…
joeylee12629-star Jul 29, 2026
f9d42f4
Add Clone Audit plugin (#5687)
bestthanapon Jul 29, 2026
00041bc
fix(e2e): bound tools-dev runtime lifecycle (#6204)
PerishCode Jul 29, 2026
30501a2
test(e2e): refresh full-pool UI fixtures (#6211)
PerishCode Jul 29, 2026
7c8c7b7
fix(web): allow skipping required chat questions (#6177)
open-design-crew[bot] Jul 29, 2026
45a3600
docs(tools-pack): add build cache contract (#6207)
PerishCode Jul 29, 2026
7af539b
docs(landing-page): add 0.16.0 release post, localize and re-cover 0.…
joeylee12629-star Jul 29, 2026
809d45f
feat(integrations): add the local Open Design Cloud flow for Codex (#…
open-design-crew[bot] Jul 30, 2026
b323a28
test: strengthen E2E coverage and fixtures (#6150)
AmyShang-alt Jul 30, 2026
4f3a0b3
fix(daemon): continue stalled post-tool sessions (#6237)
Siri-Ray Jul 30, 2026
2097115
fix(tools-pack): carry workspace build key into tarballs (#6235)
PerishCode Jul 30, 2026
8518ec8
fix(amr): recover late login failures and trace auth stages (#5986)
Siri-Ray Jul 30, 2026
176fae3
ci: constrain Actions cache writes (#6270)
PerishCode Jul 30, 2026
9757b25
fix(daemon): auto-mint assistantMessageId when clients omit pin (#6107)
mrcfps Jul 30, 2026
1da4f9e
fix(web): persist browser preview viewport (#4899)
HD-L Jul 30, 2026
4d4a659
fix(daemon): honor Kiro ACP turn completion (#6268)
Siri-Ray Jul 30, 2026
7d7c56a
fix(daemon): isolate plugin-started Local Codex runs (#6273)
open-design-crew[bot] Jul 30, 2026
0d5a972
fix(landing): accept signed Vela team plan leads (#6283)
lefarcen Jul 30, 2026
ebcba70
feat(prompts): introduce on-demand discovery in SP v2.0 (#6223)
open-design-crew[bot] Jul 31, 2026
4a6c2da
fix(web): surface BYOK migration validation errors (#6280)
open-design-crew[bot] Jul 31, 2026
2cbb2e4
fix(ui): improve dark-mode contrast for off-state toggles (#5825)
xxiaoxiong Jul 31, 2026
dac2729
fix(web): keep Azure deployment names editable (#6034)
mturac Jul 31, 2026
0c5f98e
chore(plugin-previews): refresh baked preview manifest (#6293)
open-design-release-bot[bot] Jul 31, 2026
45d1c7f
fix(byok): withdraw Windows DPAPI backend (#6308)
PerishCode Jul 31, 2026
517f39a
fix(web): dismiss stale todo after continuation starts (#6307)
Siri-Ray Jul 31, 2026
4379f86
fix(workspace): reconcile retracted Team plugins
lefarcen Aug 2, 2026
398163f
fix(workspace): close Team plugin retraction races
lefarcen Aug 2, 2026
6fc8be8
fix(workspace): fence cached Team plugin activation
lefarcen Aug 2, 2026
d867e98
fix: invalidate team resource listing cache on reconciliation
lefarcen Aug 2, 2026
3d2b733
Merge main 517f39ac into feat/workspace-team
lefarcen Aug 2, 2026
c1c20b7
Merge feat/workspace-team 4f662dd after main sync
lefarcen Aug 2, 2026
a902042
Merge workspace dashboard scope fix into main sync
lefarcen Aug 2, 2026
56e0c0b
Merge latest workspace viewer fix into main sync
lefarcen Aug 2, 2026
cec7cec
Merge latest workspace feature baseline into plugin retraction fix
lefarcen Aug 2, 2026
cb0b170
fix(workspace): fence stale plugin activation
lefarcen Aug 2, 2026
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
24 changes: 24 additions & 0 deletions apps/daemon/src/collab/team-resource-list-cache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import type { TeamResourceRequestScope } from './team-resource-share.js';

export const TEAM_RESOURCE_LIST_KINDS = ['design_system', 'plugin', 'skill'] as const;

export type TeamResourceListKind = (typeof TEAM_RESOURCE_LIST_KINDS)[number];

export interface TeamResourceListInvalidator {
invalidate(scope: TeamResourceRequestScope): void;
}

export function invalidateTeamResourceListingCaches(input: {
resourceKind?: string;
scope: TeamResourceRequestScope;
providers: Record<TeamResourceListKind, TeamResourceListInvalidator>;
invalidateSharedCommand: (workspaceId: string) => void;
}): void {
const kinds = input.resourceKind
? TEAM_RESOURCE_LIST_KINDS.filter((kind) => kind === input.resourceKind)
: TEAM_RESOURCE_LIST_KINDS;
for (const kind of kinds) {
input.providers[kind].invalidate(input.scope);
}
input.invalidateSharedCommand(input.scope.principal.teamId);
}
11 changes: 3 additions & 8 deletions apps/daemon/src/collab/workspace-resources-reconciler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,14 +41,9 @@
// state change only — this module does not delete, move, or rewrite
// anything under `USER_SKILLS_DIR` / `USER_DESIGN_SYSTEMS_DIR`.
//
// Scope: this module is resource-type-agnostic (parameterized by
// `resourceType`), but the daemon wiring in server.ts only drives it for
// `'design_system'` and `'skill'` today. `'plugin'` is a deliberate,
// documented follow-up — see server.ts's wiring comment for why (plugin's
// personal/team split runs entirely through `installed_plugins.source`'s
// `team:plugin:` prefix; it has never been bound into `workspace_resources`
// at all, so driving this reconciler for it needs that integration built
// first, not just another `resourceType` value passed in here).
// Scope: this module is resource-type-agnostic. Daemon wiring drives it for
// design systems, plugins, and skills; each materializer owns creating the
// active Team binding that this reconciler later retires.

/** This daemon's one local `workspace_resources` row for a resource, as far
* as reconciliation cares. Only rows the caller has already filtered to
Expand Down
86 changes: 83 additions & 3 deletions apps/daemon/src/plugins/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -255,18 +255,99 @@ export function rowToInstalledPlugin(row: DbRow): InstalledPluginRecord {
function pluginVisibleFromWorkspace(db: SqliteDb, pluginId: string, scope: string | null | undefined): boolean {
const binding = getWorkspaceResourceByResourceId(db, 'plugin', pluginId);
const ownerId = typeof binding?.workspaceId === 'string' ? binding.workspaceId.trim() : '';
if (binding?.resourceState === 'deleted') return false;
if (scope === undefined) return true;
const scopeId = scope?.trim();
if (!scopeId) return !ownerId;
if (!ownerId) return true;
return ownerId === scopeId;
}

/**
* A materialized Team plugin is readable only while its exact Workspace
* binding is live. An unbound marker is still accepted for one compatibility
* read so pre-binding installs can be adopted by the daemon without vanishing
* during an upgrade; callers must persist that binding immediately.
*/
export function workspaceTeamPluginBindingAllowsRead(
db: SqliteDb,
workspaceId: string,
pluginId: string,
): boolean {
const binding = getWorkspaceResourceByResourceId(
db,
'plugin',
workspaceTeamPluginBindingResourceId(workspaceId, pluginId),
);
if (!binding) return true;
return binding.workspaceId === workspaceId
&& binding.visibility === 'team'
&& binding.resourceState !== 'deleted';
}

const WORKSPACE_TEAM_PLUGIN_BINDING_PREFIX = 'team-mirror:';

/**
* Team materializations are separate from the Personal installed-plugin row.
* The generic binding table only allows one row per resource id, so a Team
* mirror must not claim the bare plugin id: Personal and Team plugins with the
* same manifest id are valid and coexist in `listWorkspacePlugins`.
*/
export function workspaceTeamPluginBindingResourceId(
workspaceId: string,
pluginId: string,
): string {
return `${WORKSPACE_TEAM_PLUGIN_BINDING_PREFIX}${encodeURIComponent(workspaceId)}:${encodeURIComponent(pluginId)}`;
}

export function pluginIdFromWorkspaceTeamPluginBinding(
workspaceId: string,
bindingResourceId: string,
): string | null {
const prefix = `${WORKSPACE_TEAM_PLUGIN_BINDING_PREFIX}${encodeURIComponent(workspaceId)}:`;
if (!bindingResourceId.startsWith(prefix)) return null;
try {
return decodeURIComponent(bindingResourceId.slice(prefix.length));
} catch {
return null;
}
}

export async function resolveWorkspaceTeamPluginWithBindingGate<T>(input: {
bindingAllowsRead: () => boolean;
resolve: () => Promise<T | null>;
}): Promise<T | null> {
if (!input.bindingAllowsRead()) return null;
const resolved = await input.resolve();
if (resolved == null || !input.bindingAllowsRead()) return null;
return resolved;
}

export async function resolveAndActivateWorkspaceTeamPlugin<T>(input: {
resolve: () => Promise<T | null>;
stillShared: () => Promise<boolean>;
activate: () => boolean;
}): Promise<T | null> {
const resolved = await input.resolve();
if (resolved == null) return null;
if (!await activateWorkspaceTeamPluginIfStillShared(input)) return null;
return resolved;
}

export async function activateWorkspaceTeamPluginIfStillShared(input: {
stillShared: () => Promise<boolean>;
activate: () => boolean;
}): Promise<boolean> {
if (!await input.stillShared()) return false;
return input.activate();
Comment thread
lefarcen marked this conversation as resolved.
}

/**
* `workspaceId` is optional and defaults to the pre-workspace-isolation
* behavior (every installed plugin, unfiltered) so every existing caller —
* behavior (every live installed plugin, otherwise unfiltered) so every existing caller —
* `od plugin list`, inventory stats, the bundled-scenario scan in server.ts —
* keeps working unchanged, AS LONG AS THEY OMIT THE ARGUMENT ENTIRELY.
* keeps working unchanged, AS LONG AS THEY OMIT THE ARGUMENT ENTIRELY. A
* reconciled tombstone is terminal even for these unscoped internal callers.
* `GET /api/plugins` always passes a second argument (`headerValue(...)`,
* which returns `string | null`, never `undefined`), so it always gets the
* workspace-scoped view even when the caller has no header — see
Expand All @@ -276,7 +357,6 @@ function pluginVisibleFromWorkspace(db: SqliteDb, pluginId: string, scope: strin
export function listInstalledPlugins(db: SqliteDb, workspaceId?: string | null): InstalledPluginRecord[] {
const rows = db.prepare(`SELECT * FROM installed_plugins ORDER BY title ASC`).all() as DbRow[];
const records = rows.map(rowToInstalledPlugin);
if (workspaceId === undefined) return records;
return records.filter((record) => pluginVisibleFromWorkspace(db, record.id, workspaceId));
}

Expand Down
57 changes: 56 additions & 1 deletion apps/daemon/src/routes/plugins/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,11 @@ export interface RegisterPluginRoutesDeps {
resourceType: string,
resourceId: string,
) => WorkspaceResourceBindingRow | null | undefined;
workspaceTeamPluginBindingAllowsRead?: (
db: SqliteDbLike,
workspaceId: string,
pluginId: string,
) => boolean;
};
plugins: {
listInstalledPlugins: (
Expand Down Expand Up @@ -338,7 +343,57 @@ export function registerPluginRoutes(app: Express, deps: RegisterPluginRoutesDep
)) return;
return helpers.installOrUpgradePlugin(req, res, 'upgrade', authority);
});
app.post('/api/plugins/:id/apply', async (req, res) => { try { const authority = await resolveWorkspaceAuthority(req, res); if (authority === undefined) return; const plugin = await resolveRequestPlugin(req.params.id, authority); if (!plugin) return res.status(404).json({ error: 'plugin not found' }); const body = req.body && typeof req.body === 'object' ? req.body as Record<string, unknown> : {}; const inputs = body.inputs && typeof body.inputs === 'object' ? body.inputs : {}; const grantCaps = Array.isArray(body.grantCaps) ? body.grantCaps.filter((c: unknown): c is string => typeof c === 'string') : []; const locale = typeof body.locale === 'string' ? body.locale : undefined; const registry = await helpers.loadPluginRegistryView(); const connectorProbe = helpers.buildConnectorProbe(helpers.connectorService); const computed = plugins.applyPlugin({ plugin, inputs, registry, locale, connectorProbe }); if (grantCaps.length > 0) { const merged = new Set([...computed.result.capabilitiesGranted, ...grantCaps]); computed.result.capabilitiesGranted = Array.from(merged); computed.result.appliedPlugin.capabilitiesGranted = Array.from(merged); } res.json({ ok: true, ...computed.result, warnings: computed.warnings, manifestSourceDigest: computed.manifestSourceDigest }); } catch (err: unknown) { if (err instanceof plugins.MissingInputError) return res.status(422).json({ error: 'missing_inputs', fields: err.fields }); res.status(500).json({ error: String(err) }); } });
app.post('/api/plugins/:id/apply', async (req, res) => {
try {
const authority = await resolveWorkspaceAuthority(req, res);
if (authority === undefined) return;
const plugin = await resolveRequestPlugin(req.params.id, authority);
if (!plugin) return res.status(404).json({ error: 'plugin not found' });
const body = req.body && typeof req.body === 'object'
? req.body as Record<string, unknown>
: {};
const inputs = body.inputs && typeof body.inputs === 'object' ? body.inputs : {};
const grantCaps = Array.isArray(body.grantCaps)
? body.grantCaps.filter((c: unknown): c is string => typeof c === 'string')
: [];
const locale = typeof body.locale === 'string' ? body.locale : undefined;
const registry = await helpers.loadPluginRegistryView();
const exactWorkspaceId = authority?.workspaceId?.trim();
if (
typeof plugin.source === 'string' &&
plugin.source.startsWith('team:plugin:') &&
(
!exactWorkspaceId ||
!workspaceResources?.workspaceTeamPluginBindingAllowsRead ||
!workspaceResources.workspaceTeamPluginBindingAllowsRead(
db,
exactWorkspaceId,
req.params.id,
)
)
) {
return res.status(404).json({ error: 'plugin not found' });
}
const connectorProbe = helpers.buildConnectorProbe(helpers.connectorService);
const computed = plugins.applyPlugin({ plugin, inputs, registry, locale, connectorProbe });
if (grantCaps.length > 0) {
const merged = new Set([...computed.result.capabilitiesGranted, ...grantCaps]);
computed.result.capabilitiesGranted = Array.from(merged);
computed.result.appliedPlugin.capabilitiesGranted = Array.from(merged);
}
res.json({
ok: true,
...computed.result,
warnings: computed.warnings,
manifestSourceDigest: computed.manifestSourceDigest,
});
} catch (err: unknown) {
if (err instanceof plugins.MissingInputError) {
return res.status(422).json({ error: 'missing_inputs', fields: err.fields });
}
res.status(500).json({ error: String(err) });
}
});
app.post('/api/plugins/:id/duplicate-project', helpers.requireLocalDaemonRequest, async (req, res) => {
let cleanupProjectId: string | null = null;
let insertedProject = false;
Expand Down
Loading
Loading