Skip to content
3 changes: 1 addition & 2 deletions packages/junior-scheduler/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,8 +81,7 @@ function scheduledTaskDispatchSource(task: ScheduledTask): Source {
return createSlackSource({
teamId: task.destination.teamId,
channelId: task.destination.channelId,

type: "priv",
type: task.conversationAccess?.visibility === "public" ? "pub" : "priv",
});
}

Expand Down
5 changes: 1 addition & 4 deletions packages/junior/src/api/conversations/access.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,10 +82,7 @@ export async function readConversationAccessFromSql(
isParticipant ||
(validRootConversationId !== undefined &&
canExposeConversationPayload({
conversationId: validRootConversationId,
...(visibility === "public" || visibility === "private"
? { visibility }
: {}),
visibility: visibility ?? undefined,
}));
return [
row.conversationId,
Expand Down
12 changes: 12 additions & 0 deletions packages/junior/src/api/conversations/projection.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {
formatSlackConversationRedactedLabel,
resolveSlackConversationContextFromThreadId,
type SlackConversationVisibility,
} from "@/chat/slack/conversation-context";
import { parseSlackThreadId } from "@/chat/slack/context";
import { buildSlackSourceUrl } from "@/chat/slack/source-link";
Expand Down Expand Up @@ -115,12 +116,14 @@ function titleFromConversation(args: {
canViewPrivateContent: boolean;
conversation: ConversationProjectionSource;
surface: ConversationSurface;
visibility?: SlackConversationVisibility;
}): string {
const slackThread = parseSlackThreadId(args.conversation.conversationId);
const effectiveChannelName = args.conversation.channelName;
const slackConversation = resolveSlackConversationContextFromThreadId({
threadId: args.conversation.conversationId,
channelName: effectiveChannelName,
...(args.visibility ? { visibility: args.visibility } : {}),
});
const privateLabel = args.canViewPrivateContent
? undefined
Expand All @@ -139,6 +142,7 @@ function titleFromConversation(args: {
function channelNameFromConversation(
conversation: ConversationProjectionSource,
canViewPrivateContent: boolean,
visibility?: SlackConversationVisibility,
): string | undefined {
const effectiveChannelName = conversation.channelName;
const slackThread = parseSlackThreadId(conversation.conversationId);
Expand All @@ -148,6 +152,7 @@ function channelNameFromConversation(
const slackConversation = resolveSlackConversationContextFromThreadId({
threadId: conversation.conversationId,
channelName: effectiveChannelName,
...(visibility ? { visibility } : {}),
});
if (!canViewPrivateContent) {
return privateConversationLabel(slackConversation);
Expand Down Expand Up @@ -194,6 +199,11 @@ export function conversationSummaryFromStoredConversation(args: {
}): ConversationSummaryReport {
const { conversation, durationMs, usage } = args;
const canViewPrivateContent = args.access?.canViewPrivateContent ?? false;
const accessVisibility = args.access?.visibility;
const visibility =
accessVisibility === "public" || accessVisibility === "private"
? accessVisibility
: undefined;
const surface = surfaceFromSource(
conversation.source,
conversation.conversationId,
Expand All @@ -207,6 +217,7 @@ export function conversationSummaryFromStoredConversation(args: {
const channelName = channelNameFromConversation(
conversation,
canViewPrivateContent,
visibility,
);
const channelNameRedacted = channelNameRedactedFromConversation(
conversation,
Expand All @@ -219,6 +230,7 @@ export function conversationSummaryFromStoredConversation(args: {
canViewPrivateContent,
conversation,
surface,
visibility,
}),
isParticipant: args.access?.isParticipant ?? false,
lastProgressAt: new Date(
Expand Down
30 changes: 17 additions & 13 deletions packages/junior/src/chat/agent/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ import {
toGenAiMessagesTraceAttributes,
type ConversationPrivacy,
} from "@/chat/conversation-privacy";
import { resolveDestinationVisibility } from "@/chat/conversations/destination-visibility";
import {
RetryableDeliveryError,
assertRunRoutingConsistency,
Expand Down Expand Up @@ -199,19 +200,19 @@ export async function executeAgentRun(
if (!request.routing.destination) {
throw new TypeError("Assistant reply generation requires a destination");
}
const channelId =
request.routing.destination.platform === "slack"
? request.routing.destination.channelId
: undefined;
Comment thread
cursor[bot] marked this conversation as resolved.
const destinationVisibility = await resolveDestinationVisibility({
destination: request.routing.destination,
visibility: request.routing.destinationVisibility,
});
const conversationPrivacy = resolveConversationPrivacy({
channelId,
conversationId: request.conversationId,
// Destination visibility is provider-neutral. Slack event context remains
// a compatibility fallback for callers that have not projected it yet.
visibility:
request.routing.destinationVisibility ??
request.routing.slackConversation?.visibility,
visibility: destinationVisibility,
});
const resolvedRequest = destinationVisibility
? {
...request,
routing: { ...request.routing, destinationVisibility },
}
: request;
const credentialActor = request.routing.credentialContext?.actor;
const actor = actorFromRouting(request.routing);
const userActor = actor && "userId" in actor ? actor : undefined;
Expand Down Expand Up @@ -243,9 +244,9 @@ export async function executeAgentRun(
assistantUserName: botConfig.userName,
};
return withLogContext(runLogContext, () =>
runWithConversationPrivacy(conversationPrivacy ?? "private", () =>
runWithConversationPrivacy(conversationPrivacy, () =>
executeAgentRunInPrivacyContext(
request,
resolvedRequest,
conversationPrivacy,
runLogContext,
streamFn,
Expand Down Expand Up @@ -419,6 +420,9 @@ async function executeAgentRunInPrivacyContext(
resume = createResumeState({
channelName: routing.slackConversation?.name,
destination: routing.destination,
...(routing.destinationVisibility
? { destinationVisibility: routing.destinationVisibility }
: {}),
...(routing.dispatch?.id ? { dispatchId: routing.dispatch.id } : {}),
durability,
getLoadedSkillNames: () => loadedSkillNamesForResume,
Expand Down
5 changes: 5 additions & 0 deletions packages/junior/src/chat/agent/resume.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,13 +41,15 @@ import {
} from "@/chat/agent/request";
import { TurnSliceLimitExceededError } from "@/chat/services/turn-limit";
import type { PluginTurnContext } from "@/chat/plugins/prompt";
import type { ConversationPrivacy } from "@/chat/conversation-privacy";

type LoadedSessionRecordState = Awaited<
ReturnType<typeof loadTurnSessionRecord>
>;
interface ResumeStateArgs {
channelName?: string;
destination: Destination;
destinationVisibility?: ConversationPrivacy;
dispatchId?: string;
durability: AgentRunDurability;
getLoadedSkillNames: () => string[];
Expand Down Expand Up @@ -92,6 +94,9 @@ export function createResumeState(args: ResumeStateArgs) {
channelName: args.channelName,
conversationId: args.conversationId,
destination: args.destination,
...(args.destinationVisibility
? { destinationVisibility: args.destinationVisibility }
: {}),
...(args.dispatchId ? { dispatchId: args.dispatchId } : {}),
source: args.runSource,
sessionId: args.turnId,
Expand Down
59 changes: 11 additions & 48 deletions packages/junior/src/chat/conversation-privacy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,70 +7,33 @@ import type {
ThinkingContent,
ToolCall,
} from "@earendil-works/pi-ai";
import { parseSlackThreadId } from "@/chat/slack/context";
import { logWarn } from "@/chat/logging";

export type ConversationPrivacy = "public" | "private";
type TraceAttributeValue = string | number | boolean | string[];
const SAFE_METADATA_KEY_LIMIT = 20;
const conversationPrivacyStorage = new AsyncLocalStorage<ConversationPrivacy>();

function privateNarrowingFromChannelId(
channelId: string | undefined,
): ConversationPrivacy | undefined {
const normalized = channelId?.trim();
if (!normalized) return undefined;
// Channel-id prefixes may only narrow toward private. `C`-prefixed ids do
// not prove a conversation public: modern Slack private channels also use
// `C` prefixes, so they stay unknown without a confirmed signal.
return normalized.startsWith("D") || normalized.startsWith("G")
? "private"
: undefined;
}

function privateNarrowingFromConversationId(
conversationId: string | undefined,
): ConversationPrivacy | undefined {
const normalized = conversationId?.trim();
if (!normalized) return undefined;
const slackThread = parseSlackThreadId(normalized);
if (slackThread) {
return privateNarrowingFromChannelId(slackThread.channelId);
}
if (normalized.startsWith("slack:")) {
return undefined;
}
// Non-Slack conversations (local CLI, internal runs) are private surfaces.
return "private";
}

/**
* Resolve whether a conversation may expose raw payloads.
*
* Only a live source signal or persisted destination visibility can classify
* a conversation public. Identifier prefixes may only narrow classification
* toward private. Unknown stays undefined so callers fail closed to private.
* Explicit destination visibility is authoritative. Missing visibility is
* observable and fails closed without inferring from identifiers.
*/
export function resolveConversationPrivacy(input: {
channelId?: string;
conversationId?: string;
/** Live source or persisted visibility, when the caller has one. */
visibility?: ConversationPrivacy;
}): ConversationPrivacy | undefined {
const narrowed =
privateNarrowingFromChannelId(input.channelId) ??
privateNarrowingFromConversationId(input.conversationId);
if (narrowed === "private") {
return "private";
/** Live or persisted destination visibility, when the caller has one. */
visibility?: ConversationPrivacy | "direct" | "unknown";
}): ConversationPrivacy {
if (input.visibility === undefined) {
logWarn("conversation.visibility.defaulted");
}
return input.visibility;
return input.visibility === "public" ? "public" : "private";
}

/** Gate raw transcript/tool payload exposure to public conversations. */
export function canExposeConversationPayload(input: {
channelId?: string;
conversationId?: string;
/** Live source or persisted visibility, when the caller has one. */
visibility?: ConversationPrivacy;
/** Live or persisted destination visibility, when the caller has one. */
visibility?: ConversationPrivacy | "direct" | "unknown";
}): boolean {
return resolveConversationPrivacy(input) === "public";
}
Expand Down
21 changes: 21 additions & 0 deletions packages/junior/src/chat/conversations/destination-visibility.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import type { Destination } from "@sentry/junior-plugin-api";
import { getConversationStore } from "@/chat/db";
import type { ConversationPrivacy } from "@/chat/conversation-privacy";

/** Read confirmed visibility from the current signal or persisted destination. */
export async function resolveDestinationVisibility(args: {
destination: Destination;
visibility?: ConversationPrivacy;
}): Promise<ConversationPrivacy | undefined> {
if (args.visibility) {
return args.visibility;
}
if (args.destination.platform === "local") {
return "private";
}
return await getConversationStore().getDestinationVisibility({
provider: "slack",
providerDestinationId: args.destination.channelId,
providerTenantId: args.destination.teamId,
});
Comment thread
cursor[bot] marked this conversation as resolved.
}
15 changes: 9 additions & 6 deletions packages/junior/src/chat/conversations/sql/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,7 @@ function destinationUpsertFromDestination(args: {
channelName?: string;
conversationId?: string;
destination: Destination | undefined;
/** Source-confirmed visibility from the current event's signal only. */
/** Confirmed destination visibility; omit when unavailable. */
visibility?: ConversationPrivacy;
}): DestinationUpsert | undefined {
const { destination } = args;
Expand All @@ -194,7 +194,7 @@ function destinationUpsertFromDestination(args: {
providerTenantId: destination.teamId,
providerDestinationId: channelId,
refreshVisibility: args.visibility !== undefined,
visibility: args.visibility ?? "private",
visibility: args.visibility ?? "unknown",
...(args.channelName ? { displayName: args.channelName } : {}),
metadata: { platform: "slack" },
};
Expand Down Expand Up @@ -719,7 +719,10 @@ export class SqlStore implements ConversationStore {
if (!row) {
return undefined;
}
return row.visibility === "public" ? "public" : "private";
if (row.visibility === "public" || row.visibility === "private") {
return row.visibility;
}
return undefined;
}

/** Serialize all durable mutations for one conversation inside a SQL transaction. */
Expand Down Expand Up @@ -900,9 +903,9 @@ export class SqlStore implements ConversationStore {
set: {
kind: sql`excluded.kind`,
displayName: sql`coalesce(excluded.display_name, ${juniorDestinations.displayName})`,
// Signal-less writes insert as private but must not clobber an
// existing public/private value. Live source signals refresh this
// field so converted channels converge on the next message.
// Signal-less writes remain unknown and must not clobber an existing
// public/private value. Live source signals refresh this field so
// converted channels converge on the next message.
visibility: visibilityUpdate,
metadata: sql`coalesce(excluded.metadata_json, ${juniorDestinations.metadata})`,
updatedAt: sql`excluded.updated_at`,
Expand Down
6 changes: 3 additions & 3 deletions packages/junior/src/chat/conversations/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ export interface Conversation {
/** Persist and read durable conversation metadata for reporting surfaces. */
export interface ConversationStore {
get(args: { conversationId: string }): Promise<Conversation | undefined>;
/** Read persisted visibility for one destination. Missing rows fail closed. */
/** Read confirmed public/private visibility for one destination. */
getDestinationVisibility(args: {
provider: string;
providerDestinationId: string;
Expand All @@ -82,7 +82,7 @@ export interface ConversationStore {
/** Source normalized to a stable session locator; set-once when absent. */
sessionSource?: Source;
title?: string;
/** Source-confirmed visibility from the current event's signal only. */
/** Confirmed destination visibility; omit when unavailable. */
visibility?: ConversationPrivacy;
}): Promise<void>;
/**
Expand All @@ -104,7 +104,7 @@ export interface ConversationStore {
source?: ConversationSource;
title?: string;
updatedAtMs: number;
/** Source-confirmed visibility from the current event's signal only. */
/** Confirmed destination visibility; omit when unavailable. */
visibility?: ConversationPrivacy;
}): Promise<void>;
listByActivity(args?: {
Expand Down
18 changes: 1 addition & 17 deletions packages/junior/src/chat/pi/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,6 @@ import {
import { toOptionalTrimmed } from "@/chat/optional-string";
import {
getCurrentConversationPrivacy,
resolveConversationPrivacy,
toCanonicalInputMessage,
toCanonicalOutputMessage,
toGenAiMessagesTraceAttributes,
Expand Down Expand Up @@ -149,22 +148,7 @@ export async function completeText(params: {
: toOptionalTrimmed(process.env.VERCEL_OIDC_TOKEN)
? "oidc"
: "api_key";
// Identifier metadata can only narrow toward private; the turn-scoped
// privacy context carries the source-confirmed classification.
const privacy =
resolveConversationPrivacy({
channelId:
typeof params.metadata?.channelId === "string"
? params.metadata.channelId
: undefined,
conversationId:
typeof params.metadata?.conversationId === "string"
? params.metadata.conversationId
: typeof params.metadata?.threadId === "string"
? params.metadata.threadId
: undefined,
}) ?? getCurrentConversationPrivacy();
const effectivePrivacy = privacy ?? "private";
const effectivePrivacy = getCurrentConversationPrivacy() ?? "private";
const messageAttributeMode =
params.messageAttributeMode ??
(effectivePrivacy === "public" ? "content" : "metadata");
Expand Down
Loading
Loading