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
3 changes: 3 additions & 0 deletions electron/host-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -470,6 +470,9 @@ const routineRuntime = createRoutineRuntime({
runTask: localMcpRuntime.runTask,
getTaskStatus: localMcpRuntime.getTaskStatus,
getWorkspaceInformation: localMcpRuntime.getWorkspaceInformation,
emitUnattendedAutomationsChanged: (payload) => {
emitEvent("routine.unattended-automations-changed", payload);
},
});
setWorkspaceScriptEventListener((envelope) => {
emitEvent("workspace-scripts.event", envelope);
Expand Down
6 changes: 6 additions & 0 deletions electron/host-service/local-mcp-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1979,6 +1979,9 @@ export async function runTask(args: {
title?: string;
provider?: ProviderId;
runtimeOptions?: ProviderRuntimeOptions;
unattendedAutomation?: {
authorizationToken: string;
};
informationReferences?: WorkspaceInformationReference[];
controlMode?: TaskControlMode;
controlOwner?: TaskControlOwner;
Expand Down Expand Up @@ -2212,6 +2215,9 @@ export async function runTask(args: {
taskId: task.id,
workspaceId: args.workspaceId,
cwd: workspacePath,
...(args.unattendedAutomation
? { unattendedAutomation: args.unattendedAutomation }
: {}),
runtimeOptions: {
...args.runtimeOptions,
model,
Expand Down
6 changes: 6 additions & 0 deletions electron/host-service/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1006,6 +1006,12 @@ export interface HostServiceEventMap {
workspaceInformation: WorkspaceInformationState;
};
"local-mcp.task-turn-updated": LocalMcpTaskTurnUpdate;
"routine.unattended-automations-changed": {
authorizations: Array<{
workspaceId: string;
authorizationToken: string;
}>;
};
}

export type HostServiceMethod = keyof HostServiceRequestMap;
Expand Down
97 changes: 87 additions & 10 deletions electron/host-service/routine-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,9 @@ interface RoutineRuntimeDependencies {
title: string;
provider: ProviderId;
runtimeOptions: ReturnType<typeof routineRuntimeToProviderOptions>;
unattendedAutomation?: {
authorizationToken: string;
};
informationReferences: RoutineUpsertInput["informationReferences"];
controlMode: "interactive";
controlOwner: "stave";
Expand All @@ -73,6 +76,12 @@ interface RoutineRuntimeDependencies {
workspaceId: string;
workspaceInformation: WorkspaceInformationState;
}>;
emitUnattendedAutomationsChanged?: (args: {
authorizations: Array<{
workspaceId: string;
authorizationToken: string;
}>;
}) => void;
now?: () => Date;
setInterval?: typeof globalThis.setInterval;
clearInterval?: typeof globalThis.clearInterval;
Expand Down Expand Up @@ -171,14 +180,22 @@ function automationRuntimeToProviderOptions(routine: RoutineSpec) {
routine.trustPolicy === "unattended" ? "never" : "untrusted",
} as const;
}
// A scheduled run has nobody to answer an approval prompt, so an unattended
// Claude run gets a real bypass. `dontAsk` used to be wired here, but that
// mode *denies* every tool outside the Stave Local MCP allowlist, which broke
// Bash, file edits, and third-party MCP servers without ever surfacing why.
if (routine.trustPolicy === "unattended") {
return {
...options,
claudePermissionMode: "bypassPermissions",
claudeAllowUnsandboxedCommands: options.claudeAllowUnsandboxedCommands,
claudeAllowDangerouslySkipPermissions: true,
} as const;
}
return {
...options,
claudePermissionMode:
routine.trustPolicy === "unattended" ? "dontAsk" : "default",
claudeAllowUnsandboxedCommands:
routine.trustPolicy === "unattended"
? options.claudeAllowUnsandboxedCommands
: false,
claudePermissionMode: "default",
claudeAllowUnsandboxedCommands: false,
claudeAllowDangerouslySkipPermissions: false,
} as const;
}
Expand Down Expand Up @@ -212,6 +229,11 @@ export function createRoutineRuntime(
const clearIntervalImpl =
dependencies.clearInterval ?? globalThis.clearInterval;
let intervalHandle: ReturnType<typeof globalThis.setInterval> | null = null;
let lastUnattendedAuthorizationKey: string | null = null;
const unattendedAuthorizationByRunId = new Map<
string,
{ workspaceId: string; authorizationToken: string }
>();
let operationChain = Promise.resolve();
let providerTimeoutMs = normalizeProviderTimeoutMs(
dependencies.persistence.loadRoutineProviderTimeoutMs(),
Expand All @@ -236,9 +258,48 @@ export function createRoutineRuntime(
runs: pruneRoutineRuns(state.runs),
});
dependencies.persistence.saveRoutineState({ state: normalized });
const activeRunIds = new Set(
normalized.runs
.filter((run) => run.status === "running" || run.status === "waiting")
.map((run) => run.id),
);
for (const runId of unattendedAuthorizationByRunId.keys()) {
if (!activeRunIds.has(runId)) {
unattendedAuthorizationByRunId.delete(runId);
}
}
publishUnattendedAutomations(normalized);
return normalized;
}

function publishUnattendedAutomations(state: RoutineState) {
const emit = dependencies.emitUnattendedAutomationsChanged;
if (!emit) {
return;
}
const authorizations = state.runs
.filter(
(run) =>
run.trustPolicy === "unattended" &&
(run.status === "running" || run.status === "waiting"),
)
.flatMap((run) => {
const authorization = unattendedAuthorizationByRunId.get(run.id);
return authorization ? [authorization] : [];
})
.sort(
(left, right) =>
left.workspaceId.localeCompare(right.workspaceId) ||
left.authorizationToken.localeCompare(right.authorizationToken),
);
const serialized = JSON.stringify(authorizations);
if (serialized === lastUnattendedAuthorizationKey) {
return;
}
lastUnattendedAuthorizationKey = serialized;
emit({ authorizations });
}

async function startRoutineRun(args: {
state: RoutineState;
routine: RoutineSpec;
Expand Down Expand Up @@ -274,6 +335,16 @@ export function createRoutineRuntime(
configHash: createAutomationConfigHash(args.routine),
trustPolicy: args.routine.trustPolicy,
};
const unattendedAutomation =
args.routine.trustPolicy === "unattended"
? { authorizationToken: randomUUID() }
: undefined;
if (unattendedAutomation) {
unattendedAuthorizationByRunId.set(run.id, {
workspaceId: run.workspaceId,
authorizationToken: unattendedAutomation.authorizationToken,
});
}
let state = saveState({
...args.state,
routines: args.state.routines.map((routine) =>
Expand Down Expand Up @@ -301,6 +372,7 @@ export function createRoutineRuntime(
...automationRuntimeToProviderOptions(args.routine),
...(providerTimeoutMs ? { providerTimeoutMs } : {}),
},
...(unattendedAutomation ? { unattendedAutomation } : {}),
informationReferences: args.routine.informationReferences,
controlMode: "interactive",
controlOwner: "stave",
Expand Down Expand Up @@ -518,6 +590,10 @@ export function createRoutineRuntime(
: run,
),
});
} else {
// Nothing was interrupted, so no save happened. Still announce the empty
// set so main starts from a known state instead of a stale one.
publishUnattendedAutomations(state);
}
const enqueueTick = () => {
void enqueue(tick).catch((error) => {
Expand All @@ -529,11 +605,12 @@ export function createRoutineRuntime(
}

function stop() {
if (!intervalHandle) {
return;
if (intervalHandle) {
clearIntervalImpl(intervalHandle);
intervalHandle = null;
}
clearIntervalImpl(intervalHandle);
intervalHandle = null;
unattendedAuthorizationByRunId.clear();
publishUnattendedAutomations(loadState());
}

return {
Expand Down
Loading
Loading