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
16 changes: 3 additions & 13 deletions skills-seed/publish/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,19 +111,9 @@ publish:

## Sharing — say who can reach it

Publication uses the conversation's default audience when `audience` is omitted.
Pass `audience: []` to suppress default grants for an owner-only publication; this does
not revoke existing explicit grants. Supply publication-time grants with `audience`:

```
apps({
action: "publish",
dir: "dist", entrypoint: "node server.js", name: "status-board",
audience: [{ scope: "org:acme", permission: "read" }],
})
```

For a later grant, use the app ID or handle:
Publishing is always private to you: a new app is reachable only by its owner, and
republishing never changes who can reach it. Grant access as a separate step with the app
ID or handle:

```
apps({ action: "share", id: "status-board", toScope: "org", permission: "read" })
Expand Down
145 changes: 16 additions & 129 deletions src/deploy/deploy-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,12 +62,9 @@ export interface DeployOrUpdateInput {
stampEnv?: Record<string, string>;
renameFrom?: string;
rollbackTo?: number;
share?: Array<{ scope: ScopeId; permission: Permission }>;
createdInScope?: ScopeId;
alwaysOn?: boolean;
embedAncestors?: string[];
public?: boolean;
defaultAudience?: { contextScopeId: ScopeId; granteeScopeIds: ScopeId[]; snapshotAt: number; force?: boolean };
}

export interface ReachOptions {
Expand Down Expand Up @@ -297,85 +294,6 @@ export function createDeployService(deps: DeployServiceDeps): DeployService {
return false;
}

async function reconcileDefaultAudience(
d: Deployment,
da: NonNullable<DeployOrUpdateInput["defaultAudience"]>,
isCreate: boolean,
explicitShares?: DeployOrUpdateInput["share"],
): Promise<void> {
if (!isCreate && !da.force && d.createdInScope && da.contextScopeId !== d.createdInScope) return;
const ref = deploymentRef(d.id);
const owner = d.createdBy;
const prior = isCreate ? [] : (d.defaultAudience?.granteeScopeIds ?? []);
const priorSet = new Set(prior);
const nextSet = new Set(da.granteeScopeIds);
const before = await grantsOn(d);
for (const grantee of prior) {
if (nextSet.has(grantee)) continue;
const explicit = before.filter((g) => g.granteeScopeId === grantee && g.permission !== "read");
await deps.acl.revoke(d.ownerScopeId, ref, grantee, owner);
for (const g of explicit) await deps.acl.grant(g);
deps.auditLog.record({
at: Date.now(),
principalId: owner,
action: "deploy_unshare",
resource: ref,
scopeLabel: grantee,
});
}
for (const grantee of da.granteeScopeIds) {
if (priorSet.has(grantee)) continue;
await deps.acl.grant({
ownerScopeId: d.ownerScopeId,
ref,
granteeScopeId: grantee,
permission: "read",
grantedBy: owner,
});
if (!explicitShares?.some((s) => s.scope === grantee))
await notifyDeploymentShared(deps, d, grantee, "read", owner);
deps.auditLog.record({
at: Date.now(),
principalId: owner,
action: "deploy_share",
resource: ref,
scopeLabel: grantee,
});
}
await deps.deployStore.setDefaultAudience(d.id, {
sourceScopeId: d.createdInScope ?? da.contextScopeId,
granteeScopeIds: da.granteeScopeIds,
snapshotAt: da.snapshotAt,
});
}

async function issueShares(
d: Deployment,
createdBy: string,
share: NonNullable<DeployOrUpdateInput["share"]>,
): Promise<void> {
for (const s of share) {
const granteeScopeId = await deploymentShareScope(s.scope, s.permission, deps.canManageEmail);
await assertShareAllowed(d.ownerScopeId, granteeScopeId, s.permission);
const grant: Grant = {
ownerScopeId: d.ownerScopeId,
ref: deploymentRef(d.id),
granteeScopeId,
permission: s.permission,
grantedBy: createdBy,
};
await deps.acl.grant(grant);
await notifyDeploymentShared(deps, d, grant.granteeScopeId, grant.permission, createdBy);
deps.auditLog.record({
at: Date.now(),
principalId: createdBy,
action: "deploy_share",
resource: deploymentRef(d.id),
scopeLabel: s.scope,
});
}
}

return {
providerProfile: deps.provider.profile,
assertShareAllowed,
Expand Down Expand Up @@ -707,14 +625,6 @@ export function createDeployService(deps: DeployServiceDeps): DeployService {

async deployOrUpdate(input) {
const { ownerScopeId, createdBy } = input;
if (input.public === true && !(await externalSharingAllowed(ownerScopeId)))
throw new Error(EXTERNAL_APP_SHARING_OFF);
for (const s of input.share ?? [])
await assertShareAllowed(
ownerScopeId,
await deploymentShareScope(s.scope, s.permission, deps.canManageEmail),
s.permission,
);
if (input.embedAncestors !== undefined && !parseEmbedAncestors(input.embedAncestors))
throw new Error(`embedAncestors must be an ${EMBED_ANCESTORS_HINT}`);

Expand Down Expand Up @@ -745,18 +655,9 @@ export function createDeployService(deps: DeployServiceDeps): DeployService {
...(input.env ? { env: input.env } : {}),
...(input.stampEnv ? { stampEnv: input.stampEnv } : {}),
});
if (input.defaultAudience)
await reconcileDefaultAudience(
(await deps.deployStore.get(existing.id))!,
input.defaultAudience,
false,
input.share,
);
} else if (input.alwaysOn !== undefined) await this.setDeploymentAlwaysOn(existing.id, input.alwaysOn);
if (input.embedAncestors !== undefined)
await this.setDeploymentEmbedAncestors(existing.id, input.embedAncestors);
if (input.public !== undefined) await this.setDeploymentPublic(existing.id, input.public, { createdBy });
if (input.share?.length) await issueShares((await deps.deployStore.get(existing.id))!, createdBy, input.share);
return (await deps.deployStore.get(existing.id))!;
}

Expand All @@ -773,15 +674,11 @@ export function createDeployService(deps: DeployServiceDeps): DeployService {
);
if (input.embedAncestors !== undefined)
await this.setDeploymentEmbedAncestors(existing.id, input.embedAncestors);
if (input.public !== undefined) await this.setDeploymentPublic(existing.id, input.public, { createdBy });
if (input.share?.length) await issueShares((await deps.deployStore.get(existing.id))!, createdBy, input.share);
return (await deps.deployStore.get(existing.id))!;
}

const files = input.files ?? [];
const existing = input.name !== undefined ? await deps.deployStore.getByName(input.name) : null;
let d: Deployment;
let isCreate: boolean;
if (existing) {
if (
existing.ownerScopeId !== ownerScopeId &&
Expand All @@ -790,41 +687,31 @@ export function createDeployService(deps: DeployServiceDeps): DeployService {
throw new Error(`deployment name taken: ${input.name}`);
}
const entrypoint = requiredEntrypoint(input.entrypoint, existing);
d = await this.redeploy(existing.id, {
await this.redeploy(existing.id, {
entrypoint,
files,
...(input.alwaysOn !== undefined ? { alwaysOn: input.alwaysOn } : {}),
...(input.homeFiles ? { homeFiles: input.homeFiles } : {}),
...(input.env ? { env: input.env } : {}),
...(input.stampEnv ? { stampEnv: input.stampEnv } : {}),
});
isCreate = false;
} else {
const entrypoint = requiredEntrypoint(input.entrypoint, existing);
d = await this.deploy({
ownerScopeId,
createdBy,
entrypoint,
files,
...(input.homeFiles ? { homeFiles: input.homeFiles } : {}),
...(input.name !== undefined ? { name: input.name } : {}),
...(input.createdInScope !== undefined ? { createdInScope: input.createdInScope } : {}),
...(input.env ? { env: input.env } : {}),
...(input.stampEnv ? { stampEnv: input.stampEnv } : {}),
...(input.alwaysOn !== undefined ? { alwaysOn: input.alwaysOn } : {}),
});
isCreate = true;
if (input.embedAncestors !== undefined)
await this.setDeploymentEmbedAncestors(existing.id, input.embedAncestors);
return (await deps.deployStore.get(existing.id))!;
}
if (input.public !== undefined) await this.setDeploymentPublic(d.id, input.public, { createdBy });
if (input.defaultAudience)
await reconcileDefaultAudience(
(await deps.deployStore.get(d.id))!,
input.defaultAudience,
isCreate,
input.share,
);
const d = await this.deploy({
ownerScopeId,
createdBy,
entrypoint: requiredEntrypoint(input.entrypoint, existing),
files,
...(input.homeFiles ? { homeFiles: input.homeFiles } : {}),
...(input.name !== undefined ? { name: input.name } : {}),
...(input.createdInScope !== undefined ? { createdInScope: input.createdInScope } : {}),
...(input.env ? { env: input.env } : {}),
...(input.stampEnv ? { stampEnv: input.stampEnv } : {}),
...(input.alwaysOn !== undefined ? { alwaysOn: input.alwaysOn } : {}),
});
if (input.embedAncestors !== undefined) await this.setDeploymentEmbedAncestors(d.id, input.embedAncestors);
if (input.share?.length) await issueShares((await deps.deployStore.get(d.id))!, createdBy, input.share);
return (await deps.deployStore.get(d.id))!;
},

Expand Down
16 changes: 5 additions & 11 deletions src/harness/agent-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1154,9 +1154,8 @@ export function createAgentTools(ref: ToolContextRef, opts?: AgentToolsOptions):
"directory exists and contains files. For a new app or a code/file update, always pass `entrypoint`; " +
"the app must listen on the PORT env var. `dir` is workspace-relative: use `app`, never a path " +
"beginning with `/` or a redundant `workspace/app`. `renameFrom` takes an existing " +
"deployment name, not its ID. Set audience to [] to suppress default audience grants, or supply " +
"publication-time grants. `public: true` makes the app reachable without sign-in; it is never the default and is refused unless an org admin has enabled external app sharing. " +
"Use apps action share for subsequent grants. Share the full absolute URL " +
"deployment name, not its ID. Publishing is always private to the owner and never changes who can reach an app; " +
"grant access separately with apps action share. Share the full absolute URL " +
"returned by apps action publish so it works in Slack and other surfaces. Use `name` for a friendly, " +
"stable link /d/<name>/; `renameFrom` to rename; `rollbackTo` to flip back to an earlier version. " +
"Egress is open, " +
Expand All @@ -1179,7 +1178,7 @@ export function createAgentTools(ref: ToolContextRef, opts?: AgentToolsOptions):
}),
{
description:
"Publication-time access grants. Omit to use the conversation's default audience; [] suppresses default grants for owner-only publication. Existing explicit grants survive. Use apps action share for subsequent grants.",
"First-publish access grants only. Omit to use the conversation's default audience; [] suppresses default grants for owner-only publication. Refused when republishing an existing app; use apps action share instead.",
},
),
),
Expand Down Expand Up @@ -1212,12 +1211,6 @@ export function createAgentTools(ref: ToolContextRef, opts?: AgentToolsOptions):
rollbackTo: Type.Optional(
Type.Integer({ description: "Flip the deployment named `name` back to this version number." }),
),
public: Type.Optional(
Type.Boolean({
description:
"Explicitly set whether anyone with the link can open the app without signing in. Defaults to private for new apps; omit to preserve the current setting on updates.",
}),
),
alwaysOn: Type.Optional(
Type.Boolean({
description:
Expand All @@ -1242,7 +1235,8 @@ export function createAgentTools(ref: ToolContextRef, opts?: AgentToolsOptions):
name: params.name,
});
try {
const r = await tc.publish({ ...params, share: params.audience } as PublishInput);
const legacy = params as { audience?: PublishInput["share"] };
const r = await tc.publish({ ...params, share: legacy.audience } as PublishInput);
const reach = describePublishAudience(r.audience);
const alwaysOnNote = r.alwaysOn ? "\nAlways-on: the app is kept warm — no idle cold starts." : "";
const embedNote = r.embedAncestors?.length ? `\nEmbeddable by: ${r.embedAncestors.join(", ")}` : "";
Expand Down
50 changes: 4 additions & 46 deletions src/tools/primitives.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,7 @@ import type {
WorkspaceLayer,
} from "../types.ts";
import { parseScopeId, scopeId } from "../types.ts";
import {
defaultPublishAudience,
type PublishAudience,
type PublishAudienceKind,
} from "../resolution/publish-audience.ts";
import { type PublishAudienceKind } from "../resolution/publish-audience.ts";
import type { WorkspaceStore } from "../workspace/workspace-store.ts";
import type { BotPolicy } from "../surface-cache/channel-policy-store.ts";
import type { GapPhase, GapWork } from "../sessions/session-store.ts";
Expand Down Expand Up @@ -1028,6 +1024,8 @@ export function createToolContext(deps: ToolContextDeps): ToolContext {

async publish(input: PublishInput): Promise<PublishResult> {
if (!writableScopeId) throw new Error("publish needs a writable scope to own the app");
if (input.share !== undefined || input.public !== undefined)
throw new Error("publish is always private to the owner; use apps action share to grant access");
const owner: ScopeId = scopeId("personal", deps.createdBy);
const createdInScope: ScopeId = writableScopeId;
let effectiveEntrypoint = input.entrypoint;
Expand Down Expand Up @@ -1060,30 +1058,6 @@ export function createToolContext(deps: ToolContextDeps): ToolContext {
)
: {};

const pc = deps.publishContext;
const aud: PublishAudience =
pc && orgScopeId
? defaultPublishAudience({
kind: pc.conversationKind,
...(pc.isPrivate !== undefined ? { isPrivate: pc.isPrivate } : {}),
...(pc.isMpim !== undefined ? { isMpim: pc.isMpim } : {}),
...(pc.publishMembers ? { members: pc.publishMembers } : {}),
orgScopeId,
ownerId: deps.createdBy,
})
: { kind: "owner", grantees: [] };
const optOut = Array.isArray(input.share) && input.share.length === 0;
const desiredDefault = optOut ? [] : aud.grantees;
const doReconcile = effectiveEntrypoint !== undefined && (optOut || !aud.incomplete);
const snapshotAt = Date.now();
const resolvedShare = input.share?.map((s) => {
const scope = s.scope === "org" ? orgScopeId : s.scope;
if (!scope) throw new Error('cannot resolve "org" — no org scope is mounted in this session');
if (parseScopeId(scope).kind === null) {
throw new Error(`invalid share target "${s.scope}" — use "org" or a scope id like personal:<id> or org:<id>`);
}
return { scope, permission: s.permission };
});
return once(async () => {
const d = await deps.deploy.deployOrUpdate({
ownerScopeId: owner,
Expand All @@ -1098,29 +1072,13 @@ export function createToolContext(deps: ToolContextDeps): ToolContext {
...(input.rollbackTo !== undefined ? { rollbackTo: input.rollbackTo } : {}),
...(input.alwaysOn !== undefined ? { alwaysOn: input.alwaysOn } : {}),
...(input.embedAncestors !== undefined ? { embedAncestors: input.embedAncestors } : {}),
...(input.public !== undefined ? { public: input.public } : {}),
...(doReconcile
? {
defaultAudience: {
contextScopeId: createdInScope,
granteeScopeIds: desiredDefault,
snapshotAt,
...(optOut ? { force: true } : {}),
},
}
: {}),
...(resolvedShare?.length ? { share: resolvedShare } : {}),
});
const ref = d.name ?? d.id;
const grantees = await deps.deploy.deploymentGrantees(d.id);
const base = audienceFromGrantees(
const audience = audienceFromGrantees(
grantees.map((g) => g.scope),
d.createdInScope,
);
const audience: PublishAudienceDescriptor =
aud.incomplete && effectiveEntrypoint !== undefined && input.share === undefined && aud.reason
? { ...base, note: aud.reason }
: base;
const urlBase = deps.publicWebUrl?.replace(/\/$/, "") ?? "";
const url = publicUrlOf(d.endpoint) ?? `${urlBase}/d/${ref}/`;
const dataDir = effectiveEntrypoint ? deps.deploy.providerProfile?.dataDir : undefined;
Expand Down
4 changes: 1 addition & 3 deletions test/agent-tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3698,7 +3698,7 @@ test("a files read approval cannot authorize writes or sharing", async () => {
}
});

test("apps preserves publication-time audience opt-out without control-plane tools", async () => {
test("apps publish forwards a legacy audience so it is refused, not silently dropped", async () => {
const inputs: unknown[] = [];
const tc = {
...fakeToolContext(),
Expand All @@ -3710,8 +3710,6 @@ test("apps preserves publication-time audience opt-out without control-plane too
const apps = createAgentTools({ current: tc }).find((tool) => tool.name === "apps")!;
await call(apps, { action: "publish", name: "private", audience: [] });
assert.deepEqual((inputs[0] as { share: unknown }).share, []);
await call(apps, { action: "publish", audience: [{ scope: "personal:bob", permission: "read" }] });
assert.deepEqual((inputs[1] as { share: unknown }).share, [{ scope: "personal:bob", permission: "read" }]);
});

test("files share preserves artifact IDs, recipient resolution and authorization failures", async () => {
Expand Down
Loading
Loading