@themoltnet/agent-daemon 0.63.0 → 0.64.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -9,9 +9,9 @@ import "multiformats/codecs/json";
9
9
  import "multiformats/hashes/sha2";
10
10
  import "typebox/value";
11
11
  import { execFile, spawn } from "node:child_process";
12
- import { basename, dirname, isAbsolute, join, resolve, sep } from "node:path";
12
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
13
13
  import { parseArgs, parseEnv, promisify } from "node:util";
14
- import { CredentialPersistenceError, EnrollmentRecoveryError, FILE_SECRET_PROVIDER, FileSecretProvider, ProjectConfigError, ProvisioningNotStartedError, RegisterIdentityError, WORKSPACE_STRATEGIES, boundedIdentitySignal, canonicalDirectory, canonicalStoreRoot, connect, createNodeSecretProviderRegistry, defaultStoreRoot, enrollTeam, getProjectConfigPath, isDefaultStore, readProjectConfig, register, resolveProjectBinding, resolveStoreRoot, resolveStoreSelection } from "@themoltnet/sdk/node";
14
+ import { CredentialPersistenceError, EnrollmentRecoveryError, FILE_SECRET_PROVIDER, FileSecretProvider, ProjectConfigError, ProvisioningNotStartedError, RegisterIdentityError, WORKSPACE_STRATEGIES, boundedIdentitySignal, canonicalDirectory, canonicalStoreRoot, connect, createNodeSecretProviderRegistry, defaultStoreRoot, enrollTeam, getProjectConfigPath, isDefaultStore, normalizeProjectEndpoint, readProjectConfig, register, resolveProjectBinding, resolveStoreRoot, resolveStoreSelection, updateProjectConfig, validateProjectConfig } from "@themoltnet/sdk/node";
15
15
  import { constants, copyFileSync, createReadStream, createWriteStream, existsSync, lstatSync, mkdirSync, mkdtempSync, openSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs";
16
16
  import { AuthenticationError, IDENTITY_ALIAS_PATTERN, MoltNetError, assertIdentityAlias, assertTrustedConfigApiUrl, createExecutorAttestor, formatSecretReferenceString, getConfigDir, getIdentityDir, hasAgentKeyConfiguration, isCanonicalConfig, parseSecretReferenceString, readConfig, requireSecureCredentialApiUrl, resolveAgentKey, resolveEnvSecretReference, resolveIdentitySeed, selectAgentKeyReference, signBytes } from "@themoltnet/sdk";
17
17
  import { AgentRuntime, ApiTaskReporter, ApiTaskSource, PollingApiTaskSource, RuntimeProfilePrerequisiteError, createLocalSeedSigner, resolveAgentIdentity, resolveRuntimeProfile, resolveRuntimeProfiles, validateRuntimeProfilePrerequisites } from "@themoltnet/agent-runtime";
@@ -866,10 +866,8 @@ var OPERATOR_OAUTH = Object.freeze({
866
866
  provisioningAudience: "moltnet:provisioning",
867
867
  localControlAudience: "moltnet:agent-server",
868
868
  nativeClientId: "moltnet-native",
869
- consoleClientId: "moltnet-console",
870
869
  approvalTransportGraceSeconds: 30,
871
870
  callbackPort: 17375,
872
- consoleLifetimeSeconds: 900,
873
871
  nativeLifetimeSeconds: 300,
874
872
  serverPort: 17374
875
873
  });
@@ -1548,7 +1546,9 @@ Type.Intersect([ConflictProblemDetailsSchema, Type.Object({ flagged: Type.Option
1548
1546
  threats: Type.Array(Type.Ref("InjectionThreat"))
1549
1547
  }, { additionalProperties: false }))) })], { $id: "InjectionConflictProblemDetails" });
1550
1548
  Type.Intersect([ProblemDetailsSchema, Type.Object({ errors: Type.Array(Type.Ref("ValidationError")) })], { $id: "ValidationProblemDetails" });
1551
- Type.Object({
1549
+ //#endregion
1550
+ //#region ../../libs/models/src/projects.ts
1551
+ var ProjectResponseSchema = Type.Object({
1552
1552
  id: Type.String({ format: "uuid" }),
1553
1553
  teamId: Type.String({ format: "uuid" }),
1554
1554
  creatorAgentId: Type.Union([Type.String({ format: "uuid" }), Type.Null()]),
@@ -3758,8 +3758,11 @@ async function resolveSelectionApiUrl(agentName, options) {
3758
3758
  * sprinkling string lookups across the codebase.
3759
3759
  */
3760
3760
  /** All daemon commands announce the same legacy alias policy as Go and Desktop. */
3761
+ function markLegacyStoreNoticeShown() {
3762
+ process.env["MOLTNET_LEGACY_STORE_NOTICE_SHOWN"] = "1";
3763
+ }
3761
3764
  function legacyStoreNotice() {
3762
- return process.env["MOLTNET_AGENT_SERVER_ROOT"] !== void 0 ? "MOLTNET_AGENT_SERVER_ROOT is deprecated; use MOLTNET_HOME. Both select the entire store; no data is migrated." : void 0;
3765
+ return process.env["MOLTNET_AGENT_SERVER_ROOT"] !== void 0 && process.env["MOLTNET_LEGACY_STORE_NOTICE_SHOWN"] !== "1" ? "MOLTNET_AGENT_SERVER_ROOT is deprecated; use MOLTNET_HOME. Both select the entire store; no data is migrated." : void 0;
3763
3766
  }
3764
3767
  function loadConfig() {
3765
3768
  assertSingleCredentialForm("MOLTNET_AGENT_KEY", "MOLTNET_AGENT_KEY_REF");
@@ -3824,14 +3827,12 @@ function loadAgentServerEnvConfig(root) {
3824
3827
  const issuer = process.env["MOLTNET_OPERATOR_OAUTH_ISSUER"];
3825
3828
  const publicUrl = process.env["MOLTNET_OPERATOR_OAUTH_PUBLIC_URL"] ?? issuer;
3826
3829
  const nativeClientId = process.env["MOLTNET_NATIVE_OAUTH_CLIENT_ID"];
3827
- const consoleClientId = process.env["MOLTNET_CONSOLE_OAUTH_CLIENT_ID"];
3828
3830
  const apiUrl = process.env["MOLTNET_OPERATOR_API_URL"];
3829
3831
  return {
3830
3832
  operatorOAuth: {
3831
3833
  ...issuer ? { issuer } : {},
3832
3834
  ...publicUrl ? { publicUrl } : {},
3833
3835
  ...nativeClientId ? { nativeClientId } : {},
3834
- ...consoleClientId ? { consoleClientId } : {},
3835
3836
  ...apiUrl ? { apiUrl } : {}
3836
3837
  },
3837
3838
  port: process.env["MOLTNET_AGENT_SERVER_PORT"] ?? "",
@@ -3859,7 +3860,8 @@ function loadUpdateEnvConfig(root) {
3859
3860
  var execFileAsync$1 = promisify(execFile);
3860
3861
  var GIT_TIMEOUT_MS = 1e4;
3861
3862
  var GIT_MAX_OUTPUT_BYTES = 64 * 1024;
3862
- async function validateGitSource(source) {
3863
+ /** `signal` stops the `git` process; an abort is reported as an abort. */
3864
+ async function validateGitSource(source, signal) {
3863
3865
  const inherited = processEnvSnapshot();
3864
3866
  try {
3865
3867
  const { stdout } = await execFileAsync$1("git", [
@@ -3877,15 +3879,37 @@ async function validateGitSource(source) {
3877
3879
  },
3878
3880
  timeout: GIT_TIMEOUT_MS,
3879
3881
  killSignal: "SIGKILL",
3880
- maxBuffer: GIT_MAX_OUTPUT_BYTES
3882
+ maxBuffer: GIT_MAX_OUTPUT_BYTES,
3883
+ ...signal ? { signal } : {}
3881
3884
  });
3882
3885
  const top = stdout.trim().split("\n")[0];
3883
3886
  if (await canonicalDirectory(top) === source) return;
3884
3887
  } catch (cause) {
3888
+ if (signal?.aborted) throw cause;
3885
3889
  throw new ProjectConfigError("selection", `git-worktree source ${source} requires a Git repository root with a committed revision: ${cause instanceof Error ? cause.message : String(cause)}`, { cause });
3886
3890
  }
3887
3891
  throw new ProjectConfigError("selection", `git-worktree source ${source} must be the Git repository root, not a subdirectory`);
3888
3892
  }
3893
+ /** Hooks that would run commands; an empty `hooks: {}` prepares nothing. */
3894
+ function hasPreparationHooks(hooks) {
3895
+ return Boolean(hooks?.afterCreate || hooks?.beforeRun);
3896
+ }
3897
+ /**
3898
+ * The one rule for whether this runtime can prepare a location. Worker
3899
+ * selection, workspace policy and Desktop readiness all call it, so a location
3900
+ * Desktop shows as ready is one a worker can start.
3901
+ */
3902
+ function preparationBlocker(strategy, hooks) {
3903
+ if (strategy === "isolated-directory") return {
3904
+ code: "unsupported_strategy",
3905
+ message: "this runtime does not support isolated-directory preparation"
3906
+ };
3907
+ if (hasPreparationHooks(hooks)) return {
3908
+ code: "hooks_unavailable",
3909
+ message: "this runtime does not support project setup hooks"
3910
+ };
3911
+ return null;
3912
+ }
3889
3913
  function projectRunOptionDefs() {
3890
3914
  return {
3891
3915
  project: { type: "string" },
@@ -3897,13 +3921,16 @@ function projectRunOptionDefs() {
3897
3921
  "workspace-strategy": { type: "string" }
3898
3922
  };
3899
3923
  }
3924
+ /** Serialize for a worker; the inverse of parsing with `projectRunOptionDefs`. */
3925
+ function projectRunArgs(flags) {
3926
+ return Object.entries(flags).flatMap(([name, value]) => value === void 0 || value === false ? [] : value === true ? [`--${name}`] : [`--${name}`, value]);
3927
+ }
3900
3928
  function strategy(value) {
3901
3929
  if (value === void 0) return void 0;
3902
3930
  if (WORKSPACE_STRATEGIES.includes(value)) return value;
3903
3931
  throw new ProjectConfigError("validation", `Unknown workspace strategy ${value}; choose ${WORKSPACE_STRATEGIES.join(", ")}`);
3904
3932
  }
3905
- /** Resolve once at worker startup. No credentials, remote calls, hooks or workspace creation. */
3906
- async function resolveRunProjectSelection(args) {
3933
+ async function resolveRunProjectSelection(args, options = {}) {
3907
3934
  const env = processEnvSnapshot();
3908
3935
  const inherited = env.MOLTNET_ACTIVE_IDENTITY === args.agent && !args.general;
3909
3936
  const bindingName = args.binding ?? (!args["config-file"] && !args.project && inherited ? env.MOLTNET_PROJECT_BINDING : void 0);
@@ -3935,8 +3962,10 @@ async function resolveRunProjectSelection(args) {
3935
3962
  if (workspaceStrategy === "none" && args.source !== void 0) throw new ProjectConfigError("selection", "No-workspace execution cannot specify a source");
3936
3963
  let source = binding?.source;
3937
3964
  if (workspaceStrategy !== "none" && !source) source = await canonicalDirectory(resolve(args.cwd, args.source ?? "."));
3938
- if (workspaceStrategy === "isolated-directory" || binding?.hooks?.afterCreate || binding?.hooks?.beforeRun) throw new ProjectConfigError("selection", `Binding ${binding?.name ?? "(run override)"} in ${configPath}: this runtime does not support isolated-directory preparation or setup hooks; choose a supported binding`);
3939
- if (workspaceStrategy === "git-worktree" && source) await validateGitSource(source);
3965
+ const blocker = preparationBlocker(workspaceStrategy, binding?.hooks);
3966
+ if (blocker) throw new ProjectConfigError("selection", `Binding ${binding?.name ?? "(run override)"} in ${configPath}: ${blocker.message}; choose a supported binding`);
3967
+ if (source && (binding || args.source !== void 0)) options.guardSource?.(source);
3968
+ if (workspaceStrategy === "git-worktree" && source) await validateGitSource(source, options.signal);
3940
3969
  return {
3941
3970
  configPath,
3942
3971
  selectedBy: args.binding || args.project ? "explicit" : bindingName ? "activation" : binding ? "ancestor" : "general",
@@ -3953,8 +3982,8 @@ async function resolveRunProjectSelection(args) {
3953
3982
  /** A selected location fixes this worker's strategy; saved profiles are never mutated. */
3954
3983
  function applyProjectWorkspacePolicy(profile, selection) {
3955
3984
  if (!selection.workspaceExplicit) return profile;
3956
- if (selection.strategy === "isolated-directory") throw new Error("This runtime does not yet support isolated-directory preparation");
3957
- if (selection.binding?.hooks?.afterCreate || selection.binding?.hooks?.beforeRun) throw new Error("This runtime does not yet support project setup hooks");
3985
+ const blocker = preparationBlocker(selection.strategy, selection.binding?.hooks);
3986
+ if (blocker) throw new Error(`Workspace preparation: ${blocker.message}`);
3958
3987
  const mode = selection.strategy === "existing" ? "shared_mount" : selection.strategy === "git-worktree" ? "dedicated_worktree" : "none";
3959
3988
  if (profile.allowedWorkspaceModes.length && !profile.allowedWorkspaceModes.includes(mode)) throw new Error(`Workspace strategy ${selection.strategy} is not allowed by profile ${profile.name}`);
3960
3989
  return {
@@ -4169,7 +4198,9 @@ Example:
4169
4198
  --agent legreffier \\
4170
4199
  --state idle`;
4171
4200
  function isHelpFlag(args) {
4172
- return args.includes("--help") || args.includes("-h");
4201
+ const separator = args.indexOf("--");
4202
+ const flags = separator === -1 ? args : args.slice(0, separator);
4203
+ return flags.includes("--help") || flags.includes("-h");
4173
4204
  }
4174
4205
  var AGENT_SERVER_HELP = `\
4175
4206
  agent-daemon server — local supervisor for managed runs.
@@ -5685,6 +5716,8 @@ function assertProviderEnvName(providerId, value) {
5685
5716
  if (value !== expected) throw new AgentServerStoreError("invalid_state", `provider envName must be ${expected}`);
5686
5717
  return value;
5687
5718
  }
5719
+ /** The run follows its runtime profile's own workspace mode. */
5720
+ var PROFILE_DEFAULT_STRATEGY = "profile-default";
5688
5721
  function readJson(path) {
5689
5722
  let raw;
5690
5723
  try {
@@ -9261,8 +9294,7 @@ var RELEASE_CONNECTION = {
9261
9294
  apiUrl: "https://api.themolt.net",
9262
9295
  issuer: "https://auth.themolt.net",
9263
9296
  publicUrl: "https://auth.themolt.net",
9264
- nativeClientId: "moltnet-native",
9265
- consoleClientId: "moltnet-console"
9297
+ nativeClientId: "moltnet-native"
9266
9298
  };
9267
9299
  var KEYS = Object.keys(RELEASE_CONNECTION);
9268
9300
  function validateConnectionOverrides(value) {
@@ -9559,7 +9591,7 @@ var OperatorOAuth = class {
9559
9591
  const command = process.platform === "darwin" ? "open" : process.platform === "win32" ? "explorer.exe" : "xdg-open";
9560
9592
  return new Promise((resolve, reject) => {
9561
9593
  execFile(command, [url], (error) => {
9562
- if (error) reject(/* @__PURE__ */ new Error("Could not open Console approval"));
9594
+ if (error) reject(/* @__PURE__ */ new Error("Could not open browser approval"));
9563
9595
  else resolve();
9564
9596
  });
9565
9597
  });
@@ -9589,22 +9621,14 @@ var OperatorOAuth = class {
9589
9621
  cancel() {
9590
9622
  this.pending?.abort();
9591
9623
  }
9624
+ operatorConfigured() {
9625
+ return this.operator !== null;
9626
+ }
9592
9627
  removeOperator() {
9593
9628
  this.cancel();
9594
9629
  rmSync(join(this.root, "operator.json"), { force: true });
9595
9630
  this.operator = null;
9596
9631
  }
9597
- metadata() {
9598
- return {
9599
- protocolVersion: OPERATOR_OAUTH.protocolVersion,
9600
- instance: this.instance,
9601
- issuer: this.config.issuer,
9602
- authorizationUrl: this.config.authorizationUrl,
9603
- tokenUrl: this.config.tokenUrl,
9604
- clientId: this.config.consoleClientId,
9605
- operatorConfigured: !!this.operator
9606
- };
9607
- }
9608
9632
  async verify(token, scope, clientId) {
9609
9633
  const { payload } = await jwtVerify(token, this.keys, {
9610
9634
  algorithms: ["RS256"],
@@ -9615,7 +9639,7 @@ var OperatorOAuth = class {
9615
9639
  "iat",
9616
9640
  "sub"
9617
9641
  ],
9618
- maxTokenAge: clientId === this.config.nativeClientId ? OPERATOR_OAUTH.nativeLifetimeSeconds : OPERATOR_OAUTH.consoleLifetimeSeconds
9642
+ maxTokenAge: OPERATOR_OAUTH.nativeLifetimeSeconds
9619
9643
  });
9620
9644
  const claims = payload.ext;
9621
9645
  const scopes = typeof payload.scope === "string" ? payload.scope.split(" ") : payload.scp;
@@ -9626,10 +9650,6 @@ var OperatorOAuth = class {
9626
9650
  provisioning: claims["moltnet:provisioning"]
9627
9651
  };
9628
9652
  }
9629
- async verifyBrowser(token) {
9630
- const operator = await this.verify(token, LOCAL_SCOPE, this.config.consoleClientId);
9631
- if (!this.operator || operator.issuer !== this.operator.issuer || operator.subject !== this.operator.subject) throw new InvalidOperatorGrantError("Native operator sign-in required");
9632
- }
9633
9653
  async authorize(grant, signal) {
9634
9654
  if (this.active) throw new Error("An approval is already pending");
9635
9655
  this.active = true;
@@ -9683,9 +9703,9 @@ var OperatorOAuth = class {
9683
9703
  ...grant ? { provisioning: JSON.stringify(grant) } : {}
9684
9704
  })) url.searchParams.set(key, value);
9685
9705
  try {
9686
- Promise.resolve(this.openBrowser(url.href)).catch(() => reject(/* @__PURE__ */ new Error("Could not open Console approval")));
9706
+ Promise.resolve(this.openBrowser(url.href)).catch(() => reject(/* @__PURE__ */ new Error("Could not open browser approval")));
9687
9707
  } catch {
9688
- reject(/* @__PURE__ */ new Error("Could not open Console approval"));
9708
+ reject(/* @__PURE__ */ new Error("Could not open browser approval"));
9689
9709
  }
9690
9710
  });
9691
9711
  });
@@ -10096,6 +10116,94 @@ function snapshot(login) {
10096
10116
  };
10097
10117
  }
10098
10118
  //#endregion
10119
+ //#region src/lib/agent-server/catalogue-project-reader.ts
10120
+ var PAGE_SIZE = 100;
10121
+ var ProjectPaginationError = class extends Error {
10122
+ name = "ProjectPaginationError";
10123
+ };
10124
+ async function readCatalogueProjects(projects, teamId) {
10125
+ const items = [];
10126
+ let offset = 0;
10127
+ for (let page = 0; page < 10; page++) {
10128
+ const result = await projects.list({
10129
+ includeArchived: false,
10130
+ limit: PAGE_SIZE,
10131
+ offset
10132
+ }, { teamId });
10133
+ items.push(...result.items);
10134
+ if (result.nextOffset === null) return {
10135
+ items,
10136
+ truncated: false
10137
+ };
10138
+ if (!Number.isSafeInteger(result.nextOffset) || result.nextOffset <= offset) throw new ProjectPaginationError("Invalid project pagination offset");
10139
+ offset = result.nextOffset;
10140
+ }
10141
+ return {
10142
+ items,
10143
+ truncated: true
10144
+ };
10145
+ }
10146
+ /** One project by id; null when the credential cannot see it. */
10147
+ async function readCatalogueProject(projects, teamId, projectId) {
10148
+ try {
10149
+ return await projects.get(projectId, { teamId });
10150
+ } catch (error) {
10151
+ if (error instanceof MoltNetError && (error.statusCode === 403 || error.statusCode === 404)) return null;
10152
+ throw error;
10153
+ }
10154
+ }
10155
+ //#endregion
10156
+ //#region src/lib/agent-server/readiness.ts
10157
+ /**
10158
+ * Whether a runtime profile can execute on *this* machine.
10159
+ *
10160
+ * A profile is authored in Console against a team; whether it can run depends
10161
+ * on local facts only the server knows — which provider keys are configured
10162
+ * here and which runtime kinds this machine can produce.
10163
+ *
10164
+ * The prerequisite comparison itself is **not** reimplemented here. Run start
10165
+ * calls `validateRuntimeProfilePrerequisites`, and the catalogue calls the same
10166
+ * function, so the composer cannot promise a run that startup would reject for
10167
+ * a reason the two evaluated differently.
10168
+ */
10169
+ function deriveProfileReadiness(profile, machine) {
10170
+ const blockers = [];
10171
+ const env = {};
10172
+ for (const [name, configured] of machine.providerEnv) if (configured) env[name] = "configured";
10173
+ try {
10174
+ validateRuntimeProfilePrerequisites(profile, env, {
10175
+ tools: machine.inventory?.tools ?? profile.requiredTools,
10176
+ executables: machine.inventory?.executables ?? profile.requiredExecutables
10177
+ });
10178
+ } catch (error) {
10179
+ if (!(error instanceof RuntimeProfilePrerequisiteError)) throw error;
10180
+ for (const name of error.missingEnv) blockers.push({
10181
+ code: "env_missing",
10182
+ message: `${name} is not configured on this machine.`,
10183
+ remedy: "Add the key under Providers, then reopen this run."
10184
+ });
10185
+ for (const name of error.missingTools) blockers.push({
10186
+ code: "tool_missing",
10187
+ message: `The runtime does not provide the tool ${name}.`,
10188
+ remedy: `Use a profile whose runtime provides ${name}, or change the profile in Console.`
10189
+ });
10190
+ for (const name of error.missingExecutables) blockers.push({
10191
+ code: "executable_missing",
10192
+ message: `The runtime does not provide the executable ${name}.`,
10193
+ remedy: `Use a runtime that ships ${name}, or drop the requirement in Console.`
10194
+ });
10195
+ }
10196
+ if (!machine.runtimeKinds.has(profile.runtimeKind)) blockers.push({
10197
+ code: "runtime_unregistered",
10198
+ message: `Runtime kind ${profile.runtimeKind} is not available on this machine.`,
10199
+ remedy: "Register the runtime under Runtimes, then reopen this run."
10200
+ });
10201
+ return {
10202
+ ready: blockers.length === 0,
10203
+ blockers
10204
+ };
10205
+ }
10206
+ //#endregion
10099
10207
  //#region src/lib/agent-server/identity.ts
10100
10208
  /**
10101
10209
  * AgentServer identity activation (#2061/#1834 boundary).
@@ -10539,7 +10647,7 @@ async function verifyTeamActivation(store, alias, managed, external, connectImpl
10539
10647
  if (missing.length) throw new TeamCredentialError({
10540
10648
  code: "agent_key_scopes_insufficient",
10541
10649
  message: `This credential lacks ${missing.join(", ")}.`,
10542
- remedy: "Renew through Console approval with the required desktop scopes."
10650
+ remedy: "Renew through browser approval with the required desktop scopes."
10543
10651
  });
10544
10652
  activated.boundTeamId = teamId;
10545
10653
  return captureTeamCredential(activated, {
@@ -10549,6 +10657,367 @@ async function verifyTeamActivation(store, alias, managed, external, connectImpl
10549
10657
  });
10550
10658
  }
10551
10659
  //#endregion
10660
+ //#region src/lib/agent-server/catalogue.ts
10661
+ /**
10662
+ * The team/profile catalogue the desktop Run Center composes runs from.
10663
+ *
10664
+ * Scoped to the *selected agent identity*, never a human session: the daemon
10665
+ * already reads runtime profiles with agent credentials on every run, so this
10666
+ * reads the same data with the same authority, earlier. An agent-scoped list is
10667
+ * also authoritative about what can actually run, which a human-scoped list is
10668
+ * not — Console needs a "this agent cannot poll that team" error precisely
10669
+ * because it offers teams the agent cannot serve.
10670
+ *
10671
+ * Team and diary travel together here for the same reason `RunSpec` pairs
10672
+ * them: the CLI's context store refuses one without the other, and a diary
10673
+ * that drifts from its team means entries land in the wrong place.
10674
+ */
10675
+ async function buildCatalogue(options) {
10676
+ const { agent, machine, identityDefault, logger } = options;
10677
+ const entries = await Promise.all(agent.teamIds.map(async (teamId) => {
10678
+ try {
10679
+ const result = await agent.readTeam(teamId);
10680
+ if (result.team.id !== teamId) throw new Error("Team response mismatch");
10681
+ const diaries = result.diaries.filter((diary) => diary.teamId === teamId).map(({ id, name }) => ({
10682
+ id,
10683
+ name
10684
+ }));
10685
+ const team = {
10686
+ teamId,
10687
+ teamName: result.team.name,
10688
+ available: true,
10689
+ blockers: [],
10690
+ credential: result.credential,
10691
+ diaries,
10692
+ defaultDiaryId: resolveDefaultDiary(teamId, diaries, identityDefault)
10693
+ };
10694
+ const profiles = result.profiles.filter((profile) => profile.teamId === teamId).map((profile) => ({
10695
+ ...profile,
10696
+ ...deriveProfileReadiness(profile, machine)
10697
+ }));
10698
+ try {
10699
+ const page = await agent.readProjects(teamId);
10700
+ return {
10701
+ team,
10702
+ profiles,
10703
+ projects: page.items.filter((project) => project.teamId === teamId && !project.archived),
10704
+ projectErrors: page.truncated ? [{
10705
+ teamId,
10706
+ code: "truncated",
10707
+ message: "Only the first projects are listed. Archive unused projects to see the rest."
10708
+ }] : []
10709
+ };
10710
+ } catch (error) {
10711
+ logger?.warn({
10712
+ ...safeErrorContext(error),
10713
+ teamId,
10714
+ code: "agent_server_project_discovery_failed"
10715
+ }, "AgentServer project discovery failed");
10716
+ return {
10717
+ team,
10718
+ profiles,
10719
+ projects: [],
10720
+ projectErrors: [projectError(teamId, error)]
10721
+ };
10722
+ }
10723
+ } catch (error) {
10724
+ const blocker = credentialBlocker(error);
10725
+ logger?.warn({
10726
+ ...safeErrorContext(error),
10727
+ teamId,
10728
+ blocker: blocker.code,
10729
+ code: "agent_server_team_unavailable"
10730
+ }, "AgentServer team credential unavailable");
10731
+ return {
10732
+ team: {
10733
+ teamId,
10734
+ teamName: teamId,
10735
+ available: false,
10736
+ blockers: [blocker],
10737
+ credential: agent.lastVerified(teamId),
10738
+ diaries: [],
10739
+ defaultDiaryId: null
10740
+ },
10741
+ profiles: [],
10742
+ projects: [],
10743
+ projectErrors: []
10744
+ };
10745
+ }
10746
+ }));
10747
+ const teams = entries.map(({ team }) => team);
10748
+ const available = teams.filter((team) => team.available);
10749
+ return {
10750
+ teams,
10751
+ defaultTeamId: available.find((team) => team.teamId === identityDefault.teamId)?.teamId ?? available[0]?.teamId ?? null,
10752
+ profiles: entries.flatMap((entry) => entry.profiles),
10753
+ projects: entries.flatMap((entry) => entry.projects),
10754
+ projectErrors: entries.flatMap((entry) => entry.projectErrors)
10755
+ };
10756
+ }
10757
+ function projectError(teamId, error) {
10758
+ if (error instanceof MoltNetError && (error.statusCode === 401 || error.statusCode === 403)) return {
10759
+ teamId,
10760
+ code: "forbidden",
10761
+ message: "This team credential cannot list projects. Renew it with project access."
10762
+ };
10763
+ if (error instanceof ProjectPaginationError) return {
10764
+ teamId,
10765
+ code: "invalid_response",
10766
+ message: "The server returned an unreadable project list."
10767
+ };
10768
+ return {
10769
+ teamId,
10770
+ code: "unreachable",
10771
+ message: "Projects could not be loaded. Retry project discovery."
10772
+ };
10773
+ }
10774
+ /** The one default-diary rule, shared with General run start. */
10775
+ function resolveDefaultDiary(teamId, diaries, identityDefault) {
10776
+ const bound = diaries.find((diary) => diary.id === identityDefault.diaryId);
10777
+ if (bound && identityDefault.teamId === teamId) return bound.id;
10778
+ return diaries.length === 1 ? diaries[0]?.id ?? null : null;
10779
+ }
10780
+ //#endregion
10781
+ //#region src/lib/agent-server/http-error.ts
10782
+ /** A route failure with a stable wire code; the server error handler maps it verbatim. */
10783
+ var AgentServerHttpError = class extends Error {
10784
+ name = "AgentServerHttpError";
10785
+ constructor(statusCode, code, message, options) {
10786
+ super(message, options);
10787
+ this.statusCode = statusCode;
10788
+ this.code = code;
10789
+ }
10790
+ };
10791
+ //#endregion
10792
+ //#region src/lib/agent-server/identity-binding.ts
10793
+ /**
10794
+ * The identity-wide team/diary binding, read from `<identityDir>/env`.
10795
+ *
10796
+ * This mirrors the Go CLI's `identityDefaultBinding` (`project_selection.go`),
10797
+ * which is the fallback the CLI uses when a working directory has no
10798
+ * registered project binding.
10799
+ *
10800
+ * The desktop cannot use the CLI's *location* bindings at all — its composer
10801
+ * has no working directory to key on — but it can honour this identity-wide
10802
+ * default, so an operator sees their familiar team preselected rather than an
10803
+ * arbitrary first entry.
10804
+ *
10805
+ * Like the CLI, a half-filled pair is treated as no binding: a team without a
10806
+ * diary is ignored.
10807
+ */
10808
+ function readIdentityDefaultBinding(identityDir) {
10809
+ let contents;
10810
+ try {
10811
+ contents = readFileSync(join(identityDir, "env"), "utf8");
10812
+ } catch {
10813
+ return {};
10814
+ }
10815
+ const env = parseEnv(contents);
10816
+ const teamId = env["MOLTNET_TEAM_ID"]?.trim();
10817
+ const diaryId = env["MOLTNET_DIARY_ID"]?.trim();
10818
+ if (!teamId || !diaryId) return {};
10819
+ return {
10820
+ teamId,
10821
+ diaryId
10822
+ };
10823
+ }
10824
+ async function verifyProjectTarget(reader, target, options) {
10825
+ const { signal, logger } = options;
10826
+ let project = null;
10827
+ let diary = null;
10828
+ try {
10829
+ await untilAborted(signal, async () => {
10830
+ if (target.projectId) project = await reader.readProject(target.teamId, target.projectId, signal);
10831
+ if (target.diaryId) diary = await reader.readDiary(target.teamId, target.diaryId, signal);
10832
+ });
10833
+ } catch (error) {
10834
+ if (!signal.aborted) {
10835
+ if (error instanceof TeamCredentialError) throw error;
10836
+ if (notVisible(error)) throw projectUnavailable();
10837
+ }
10838
+ logger?.warn({
10839
+ ...safeErrorContext(error),
10840
+ teamId: target.teamId,
10841
+ ...target.projectId ? { projectId: target.projectId } : {},
10842
+ ...target.diaryId ? { diaryId: target.diaryId } : {},
10843
+ code: signal.aborted ? "agent_server_project_check_aborted" : "agent_server_project_check_failed"
10844
+ }, "AgentServer project check failed");
10845
+ throw checkUnavailable(error, signal.aborted);
10846
+ }
10847
+ const found = project;
10848
+ if (target.projectId && (!found || found.id !== target.projectId || found.teamId !== target.teamId || found.archived)) throw projectUnavailable();
10849
+ const foundDiary = diary;
10850
+ if (target.diaryId && (!foundDiary || foundDiary.id !== target.diaryId || foundDiary.teamId !== target.teamId)) throw new AgentServerHttpError(400, "diary_unavailable", "Choose a diary belonging to this team");
10851
+ return { project: found };
10852
+ }
10853
+ /** Null when the credential cannot see the resource; other failures propagate. */
10854
+ async function visibleOrNull(read) {
10855
+ try {
10856
+ return await read();
10857
+ } catch (error) {
10858
+ if (notVisible(error)) return null;
10859
+ throw error;
10860
+ }
10861
+ }
10862
+ /**
10863
+ * Settles when `work` does or `signal` aborts. The SDK's team, project and
10864
+ * diary reads take no signal, so an in-flight GET may finish in the background;
10865
+ * readers check the signal between steps so nothing further starts.
10866
+ */
10867
+ async function untilAborted(signal, work) {
10868
+ signal.throwIfAborted();
10869
+ const aborted = new Promise((_, reject) => {
10870
+ signal.addEventListener("abort", () => {
10871
+ const reason = signal.reason;
10872
+ reject(reason instanceof Error ? reason : new Error(String(reason)));
10873
+ }, { once: true });
10874
+ });
10875
+ return Promise.race([work(), aborted]);
10876
+ }
10877
+ /** True only when `cause` is the abort itself, not a failure seen after it fired. */
10878
+ function causedByAbort(cause, signal) {
10879
+ if (!signal.aborted) return false;
10880
+ if (cause === signal.reason) return true;
10881
+ const error = cause;
10882
+ return error?.cause === signal.reason || error?.name === "AbortError" || error?.name === "TimeoutError";
10883
+ }
10884
+ function checkUnavailable(cause, timedOut) {
10885
+ return new AgentServerHttpError(503, "project_check_unavailable", timedOut ? "The request could not be completed in time, so nothing was changed. Retry in a moment." : "The server could not confirm this project. Retry in a moment.", { cause });
10886
+ }
10887
+ function projectUnavailable() {
10888
+ return new AgentServerHttpError(400, "project_unavailable", "Verify team access and choose an available project");
10889
+ }
10890
+ function notVisible(error) {
10891
+ return error instanceof MoltNetError && [
10892
+ 401,
10893
+ 403,
10894
+ 404
10895
+ ].includes(error.statusCode ?? 0);
10896
+ }
10897
+ //#endregion
10898
+ //#region src/lib/agent-server/protected-roots.ts
10899
+ /**
10900
+ * A worker holds agent and provider keys, so neither a run nor a saved
10901
+ * location may use a folder inside, or containing, the MoltNet store or its
10902
+ * secrets. Both sides are compared canonically.
10903
+ */
10904
+ function isProtectedFolder(source, roots) {
10905
+ const folder = canonicalOrSelf(source);
10906
+ return roots.some((root) => {
10907
+ const protectedRoot = canonicalOrSelf(root);
10908
+ return within(folder, protectedRoot) || within(protectedRoot, folder);
10909
+ });
10910
+ }
10911
+ var PROTECTED_FOLDER_MESSAGE = "Choose a folder outside the MoltNet configuration store";
10912
+ function canonicalOrSelf(path) {
10913
+ try {
10914
+ return realpathSync.native(path);
10915
+ } catch {
10916
+ return path;
10917
+ }
10918
+ }
10919
+ function within(child, parent) {
10920
+ const suffix = relative(parent, child);
10921
+ return suffix === "" || !isAbsolute(suffix) && suffix !== ".." && !suffix.startsWith(`..${sep}`);
10922
+ }
10923
+ //#endregion
10924
+ //#region src/lib/agent-server/managed-project-selection.ts
10925
+ /** True when the start request names any project selection field. */
10926
+ function requestsProjectSelection(spec) {
10927
+ return spec.projectId !== void 0 || spec.location !== void 0 || spec.source !== void 0 || spec.strategy !== void 0;
10928
+ }
10929
+ /**
10930
+ * Resolve against the base store once; the worker receives only this captured
10931
+ * config. The request in `spec` is never modified: resolved values are
10932
+ * returned in `workspace` and `effective`, so a run can be replayed from what
10933
+ * the caller asked for.
10934
+ */
10935
+ async function resolveManagedProjectSelection(options) {
10936
+ const { spec, root, cwd, apiUrl, client, signal } = options;
10937
+ if (spec.source !== void 0) {
10938
+ if (!isAbsolute(spec.source)) throw new ProjectConfigError("selection", "Select an absolute source folder");
10939
+ let canonical;
10940
+ try {
10941
+ canonical = await canonicalDirectory(spec.source);
10942
+ } catch (cause) {
10943
+ throw new ProjectConfigError("selection", "The selected folder is unavailable. Choose an existing folder.", { cause });
10944
+ }
10945
+ assertOutsideProtected(canonical, options.protectedRoots);
10946
+ }
10947
+ if (spec.projectId === null && spec.location) throw new ProjectConfigError("selection", "General work cannot also name a location");
10948
+ const selection = await resolveRunProjectSelection({
10949
+ agent: spec.agent,
10950
+ cwd,
10951
+ team: spec.teamId,
10952
+ apiUrl,
10953
+ "config-file": getProjectConfigPath({ root }),
10954
+ general: !spec.projectId && !spec.location,
10955
+ project: spec.projectId ?? void 0,
10956
+ binding: spec.location,
10957
+ source: spec.source,
10958
+ "workspace-strategy": spec.strategy
10959
+ }, {
10960
+ signal,
10961
+ guardSource: (source) => assertOutsideProtected(source, options.protectedRoots)
10962
+ });
10963
+ const chosenSource = selection.workspaceExplicit ? selection.source : void 0;
10964
+ const reader = {
10965
+ readProject: (teamId, projectId, readSignal) => {
10966
+ readSignal.throwIfAborted();
10967
+ return visibleOrNull(() => client.projects.get(projectId, { teamId }));
10968
+ },
10969
+ readDiary: (teamId, diaryId, readSignal) => {
10970
+ readSignal.throwIfAborted();
10971
+ return visibleOrNull(() => client.diaries.get(diaryId, { teamId }));
10972
+ }
10973
+ };
10974
+ const check = {
10975
+ signal,
10976
+ ...options.logger ? { logger: options.logger } : {}
10977
+ };
10978
+ const { project } = await verifyProjectTarget(reader, {
10979
+ teamId: spec.teamId,
10980
+ projectId: selection.projectId
10981
+ }, check);
10982
+ const diaryId = spec.diaryId ?? selection.binding?.diaryId ?? project?.defaultDiaryId ?? (selection.projectId === null ? await options.generalDefaultDiary?.() : void 0) ?? void 0;
10983
+ if (diaryId) await verifyProjectTarget(reader, {
10984
+ teamId: spec.teamId,
10985
+ projectId: null,
10986
+ diaryId
10987
+ }, check);
10988
+ const resolvedBinding = selection.binding ? {
10989
+ ...selection.binding,
10990
+ ...diaryId ? { diaryId } : {}
10991
+ } : void 0;
10992
+ const config = {
10993
+ version: 1,
10994
+ bindings: resolvedBinding ? [resolvedBinding] : []
10995
+ };
10996
+ const workspace = {
10997
+ projectId: selection.projectId,
10998
+ ...resolvedBinding ? { location: resolvedBinding.name } : {},
10999
+ ...diaryId ? { diaryId } : {},
11000
+ ...chosenSource ? { source: chosenSource } : {},
11001
+ strategy: selection.workspaceExplicit ? selection.strategy : PROFILE_DEFAULT_STRATEGY
11002
+ };
11003
+ return {
11004
+ selection: {
11005
+ ...selection,
11006
+ binding: resolvedBinding
11007
+ },
11008
+ workspace,
11009
+ config,
11010
+ effective: {
11011
+ ...spec,
11012
+ projectId: selection.projectId,
11013
+ ...diaryId ? { diaryId } : {}
11014
+ }
11015
+ };
11016
+ }
11017
+ function assertOutsideProtected(source, roots) {
11018
+ if (isProtectedFolder(source, roots)) throw new ProjectConfigError("selection", PROTECTED_FOLDER_MESSAGE);
11019
+ }
11020
+ //#endregion
10552
11021
  //#region src/lib/agent-server/runs.ts
10553
11022
  var STOP_GRACE_MS = 1e4;
10554
11023
  var STOP_FORCE_MS = 2e3;
@@ -10637,7 +11106,7 @@ var RunManager = class {
10637
11106
  };
10638
11107
  }
10639
11108
  /** Assemble child env + args for a run. Exposed for tests. */
10640
- async prepare(spec, agent, piDir, providers = this.store.readProviders(), runtimeModule) {
11109
+ async prepare(spec, agent, piDir, providers = this.store.readProviders(), runtimeModule, workspace) {
10641
11110
  const { activation, config } = agent;
10642
11111
  const homeDir = join(dirname(piDir), "home");
10643
11112
  const env = {
@@ -10682,7 +11151,16 @@ var RunManager = class {
10682
11151
  String(runtimeSettings.heartbeatIntervalMs),
10683
11152
  "--warm-retention-sec",
10684
11153
  String(runtimeSettings.warmRetentionSec),
10685
- ...target.extraArgs
11154
+ ...target.extraArgs,
11155
+ ...workspace ? projectRunArgs({
11156
+ "config-file": workspace.configPath,
11157
+ ...workspace.location ? { binding: workspace.location } : { general: true },
11158
+ ...workspace.strategy === "profile-default" ? {} : {
11159
+ source: workspace.source,
11160
+ "workspace-strategy": workspace.strategy
11161
+ },
11162
+ "state-dir": this.runStateDir(spec, workspace)
11163
+ }) : []
10686
11164
  ];
10687
11165
  env["MOLTNET_AGENT_KEY"] = requireCredentialSnapshot(agent).agentKey;
10688
11166
  env["MOLTNET_API_URL"] = activation.apiUrl ?? (activation.source === "external" ? activation.configApiUrl : "");
@@ -10713,10 +11191,11 @@ var RunManager = class {
10713
11191
  return {
10714
11192
  args,
10715
11193
  env,
10716
- cwd: target.cwd
11194
+ cwd: workspace?.source ?? (workspace ? join(dirname(piDir), "workspace") : target.cwd)
10717
11195
  };
10718
11196
  }
10719
11197
  async start(spec, signal) {
11198
+ spec = structuredClone(spec);
10720
11199
  validateRunSpec(spec);
10721
11200
  const releaseStart = this.reserveStart(spec.agent);
10722
11201
  try {
@@ -10727,24 +11206,65 @@ var RunManager = class {
10727
11206
  }
10728
11207
  async startReserved(spec, signal) {
10729
11208
  this.assertStartOpen(signal);
10730
- const agent = await (this.options.verifyActivationImpl ?? verifyTeamActivation)(this.store, spec.agent, this.options.secretProviders, this.options.externalSecretProviders, void 0, signal, spec.teamId).catch((cause) => {
11209
+ const deadline = AbortSignal.any([...signal ? [signal] : [], AbortSignal.timeout(this.options.startTimeoutMs ?? 1e4)]);
11210
+ const verify = this.options.verifyActivationImpl ?? verifyTeamActivation;
11211
+ const agent = await untilAborted(deadline, () => verify(this.store, spec.agent, this.options.secretProviders, this.options.externalSecretProviders, void 0, deadline, spec.teamId)).catch((cause) => {
11212
+ this.assertStartOpen(signal, deadline, cause);
10731
11213
  if (cause instanceof TeamCredentialError || cause instanceof AgentServerStoreError) throw cause;
10732
11214
  throw new AgentServerIdentityError("verification_failed", `Cannot start agent "${spec.agent}" for team "${spec.teamId}": credential verification failed. Check the selected team key and activation.`);
10733
11215
  });
10734
- this.assertStartOpen(signal);
11216
+ this.assertStartOpen(signal, deadline);
10735
11217
  if (agent.boundTeamId && agent.boundTeamId !== spec.teamId) throw new AgentServerRunError("invalid_spec", `agent "${spec.agent}" has a key bound to team ${agent.boundTeamId}; start the run with that team, or create a new agent with an enrollment token from team ${spec.teamId}`);
10736
11218
  const id = `${Date.now().toString(36)}-${randomBytes(4).toString("hex")}`;
10737
- const runDir = this.store.runDir(id);
11219
+ const runDir = resolve(this.store.runDir(id));
10738
11220
  const piDir = join(runDir, "pi");
10739
- const providers = this.store.readProviders();
10740
- const runtimeModule = this.options.resolveRuntimeModule ? await this.options.resolveRuntimeModule(spec, agent, dirname(piDir)) : this.options.runtimeRegistry ? await this.resolveRuntimeModule(spec, agent, dirname(piDir)) : void 0;
10741
- const { args, env, cwd } = await this.prepare(spec, agent, piDir, providers, runtimeModule);
10742
- this.assertStartOpen(signal);
10743
11221
  let child;
10744
11222
  let logStream;
10745
11223
  let logLimiter;
11224
+ let workspace;
11225
+ let effective = spec;
11226
+ let selection;
10746
11227
  try {
10747
11228
  const { logPath } = this.store.createRunDir(id);
11229
+ const executionDir = join(runDir, "workspace");
11230
+ if (requestsProjectSelection(spec)) {
11231
+ mkdirSync(executionDir, {
11232
+ recursive: true,
11233
+ mode: 448
11234
+ });
11235
+ const projectRoot = this.options.projectRoot ?? this.store.root;
11236
+ const resolved = await resolveManagedProjectSelection({
11237
+ spec,
11238
+ root: projectRoot,
11239
+ cwd: executionDir,
11240
+ apiUrl: agent.activation.apiUrl ?? agent.config.endpoints.api,
11241
+ client: requireCredentialSnapshot(agent).client,
11242
+ protectedRoots: [
11243
+ projectRoot,
11244
+ this.store.root,
11245
+ this.store.secretsDir
11246
+ ],
11247
+ signal: deadline,
11248
+ logger: { warn: (context, message) => this.log("warn", message, context) },
11249
+ generalDefaultDiary: () => this.generalDefaultDiary(spec, agent, deadline)
11250
+ });
11251
+ workspace = {
11252
+ ...resolved.workspace,
11253
+ configPath: join(runDir, "projects.json")
11254
+ };
11255
+ effective = resolved.effective;
11256
+ selection = resolved.selection;
11257
+ writeRunSnapshot(workspace.configPath, resolved.config);
11258
+ mkdirSync(this.runStateDir(spec, workspace), {
11259
+ recursive: true,
11260
+ mode: 448
11261
+ });
11262
+ }
11263
+ const providers = this.store.readProviders();
11264
+ const executionCwd = workspace ? workspace.source ?? executionDir : dirname(piDir);
11265
+ const runtimeModule = await untilAborted(deadline, async () => this.options.resolveRuntimeModule ? this.options.resolveRuntimeModule(effective, agent, executionCwd) : this.options.runtimeRegistry ? this.resolveRuntimeModule(effective, agent, executionCwd, selection) : void 0);
11266
+ const { args, env, cwd } = await this.prepare(effective, agent, piDir, providers, runtimeModule, workspace);
11267
+ this.assertStartOpen(signal, deadline);
10748
11268
  for (const dir of [
10749
11269
  env.HOME,
10750
11270
  env.XDG_CACHE_HOME,
@@ -10775,7 +11295,7 @@ var RunManager = class {
10775
11295
  child?.kill("SIGKILL");
10776
11296
  });
10777
11297
  const spawnImpl = this.options.spawnImpl ?? spawn;
10778
- this.assertStartOpen(signal);
11298
+ this.assertStartOpen(signal, deadline);
10779
11299
  child = spawnImpl(entry.execPath, [
10780
11300
  ...entry.execArgv,
10781
11301
  entry.scriptPath,
@@ -10803,6 +11323,7 @@ var RunManager = class {
10803
11323
  child.stderr?.pipe(logLimiter, { end: false });
10804
11324
  const record = {
10805
11325
  ...spec,
11326
+ ...workspace ? { workspace } : {},
10806
11327
  id,
10807
11328
  status: "running",
10808
11329
  pid: child.pid,
@@ -10845,6 +11366,7 @@ var RunManager = class {
10845
11366
  this.store.writeRun(record);
10846
11367
  this.log("info", "agent server run started", {
10847
11368
  ...runContext(id, spec.agent, child),
11369
+ ...selectionContext(spec, workspace),
10848
11370
  transition: "running"
10849
11371
  });
10850
11372
  return record;
@@ -10859,20 +11381,29 @@ var RunManager = class {
10859
11381
  });
10860
11382
  this.log("error", "agent server run failed to start", {
10861
11383
  ...runContext(id, spec.agent, child),
11384
+ ...selectionContext(spec, workspace),
10862
11385
  transition: "start_failed",
10863
11386
  ...safeRunError(cause)
10864
11387
  });
11388
+ if (!signal?.aborted && !this.closing && !(cause instanceof AgentServerHttpError) && causedByAbort(cause, deadline)) throw startTimedOut(cause);
10865
11389
  throw cause;
10866
11390
  }
10867
11391
  }
10868
- async resolveRuntimeModule(spec, activated, cwd) {
10869
- const profiles = await resolveRuntimeProfiles({
11392
+ async resolveRuntimeModule(spec, activated, cwd, selection) {
11393
+ const effectiveProfiles = (await resolveRuntimeProfiles({
10870
11394
  agent: await this.connectAgent(activated, spec.teamId),
10871
11395
  profiles: spec.profiles,
10872
11396
  teamId: spec.teamId,
10873
11397
  cwd
11398
+ })).map((profile) => {
11399
+ if (!selection) return profile;
11400
+ try {
11401
+ return applyProjectWorkspacePolicy(profile, selection);
11402
+ } catch (error) {
11403
+ throw new AgentServerRunError("invalid_spec", error instanceof Error ? error.message : "This profile does not support the selected workspace strategy");
11404
+ }
10874
11405
  });
10875
- const kinds = [...new Set(profiles.map((profile) => profile.runtimeKind))];
11406
+ const kinds = [...new Set(effectiveProfiles.map((profile) => profile.runtimeKind))];
10876
11407
  if (kinds.length !== 1) throw new AgentServerRunError("invalid_spec", "All profiles in a server-managed run must use the same runtime kind.");
10877
11408
  const kind = kinds[0];
10878
11409
  let registration;
@@ -11017,8 +11548,10 @@ var RunManager = class {
11017
11548
  else this.startingByAgent.delete(agent);
11018
11549
  };
11019
11550
  }
11020
- assertStartOpen(signal) {
11551
+ /** Shutdown or disconnect wins; otherwise an expired budget is a retryable 503. */
11552
+ assertStartOpen(signal, deadline, cause) {
11021
11553
  if (this.closing || signal?.aborted) throw new AgentServerRunError("invalid_spec", "agent server is shutting down");
11554
+ if (deadline?.aborted && (cause === void 0 || causedByAbort(cause, deadline))) throw startTimedOut(cause);
11022
11555
  }
11023
11556
  pruneCompletedRuns() {
11024
11557
  const removed = this.store.pruneCompletedRuns({
@@ -11050,10 +11583,40 @@ var RunManager = class {
11050
11583
  });
11051
11584
  }
11052
11585
  }
11053
- log(level, message, context) {
11054
- this.options.logger?.[level](context, message);
11055
- }
11056
- };
11586
+ /**
11587
+ * Same rule as the catalogue. Best effort: without it the worker resolves its
11588
+ * own diary, as it did before project selection existed.
11589
+ */
11590
+ async generalDefaultDiary(spec, agent, deadline) {
11591
+ try {
11592
+ const { items } = await untilAborted(deadline, () => requireCredentialSnapshot(agent).client.diaries.list());
11593
+ return resolveDefaultDiary(spec.teamId, items.filter((diary) => diary.teamId === spec.teamId), readIdentityDefaultBinding(this.store.identityDir(spec.agent))) ?? void 0;
11594
+ } catch (error) {
11595
+ this.log("warn", "agent server default diary lookup failed", {
11596
+ ...safeRunError(error),
11597
+ agent: spec.agent,
11598
+ teamId: spec.teamId
11599
+ });
11600
+ return;
11601
+ }
11602
+ }
11603
+ /**
11604
+ * Stable across runs, so retries and continuations find their execution-plan
11605
+ * cache and task workspaces. Keyed by agent and location, under the store,
11606
+ * never inside the user's folder.
11607
+ */
11608
+ runStateDir(spec, workspace) {
11609
+ const scope = createHash("sha256").update(JSON.stringify([
11610
+ spec.teamId,
11611
+ workspace.projectId,
11612
+ workspace.source ?? null
11613
+ ])).digest("hex").slice(0, 12);
11614
+ return join(this.store.root, "run-state", spec.agent, `${workspace.location ? locationSegment(workspace.location) : "general"}-${scope}`);
11615
+ }
11616
+ log(level, message, context) {
11617
+ this.options.logger?.[level](context, message);
11618
+ }
11619
+ };
11057
11620
  function createByteLimitTransform(limit, onTruncated) {
11058
11621
  const byteLimit = Math.max(0, Math.floor(limit));
11059
11622
  const contentLimit = Math.max(0, byteLimit - LOG_TRUNCATION_MARKER.length);
@@ -11077,6 +11640,33 @@ function createByteLimitTransform(limit, onTruncated) {
11077
11640
  callback();
11078
11641
  } });
11079
11642
  }
11643
+ /** Non-secret ids that tie a run's logs to its project selection. */
11644
+ function selectionContext(spec, workspace) {
11645
+ return {
11646
+ ...spec.projectId !== void 0 ? { requestedProjectId: spec.projectId } : {},
11647
+ ...spec.location ? { requestedLocation: spec.location } : {},
11648
+ ...workspace ? {
11649
+ projectId: workspace.projectId,
11650
+ ...workspace.location ? { location: workspace.location } : {},
11651
+ ...workspace.diaryId ? { diaryId: workspace.diaryId } : {},
11652
+ strategy: workspace.strategy
11653
+ } : {}
11654
+ };
11655
+ }
11656
+ /** Readable when it is a safe path segment; hashed otherwise. */
11657
+ function locationSegment(name) {
11658
+ return /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/u.test(name) ? `location-${name}` : `location-${createHash("sha256").update(name).digest("hex").slice(0, 16)}`;
11659
+ }
11660
+ function startTimedOut(cause) {
11661
+ return new AgentServerHttpError(503, "start_timeout", "The run could not be prepared in time, so it was not started. Retry in a moment.", { cause });
11662
+ }
11663
+ /** Created exclusively and owner-only: a run never reuses or widens a snapshot. */
11664
+ function writeRunSnapshot(path, config) {
11665
+ writeFileSync(path, JSON.stringify(config), {
11666
+ mode: 384,
11667
+ flag: "wx"
11668
+ });
11669
+ }
11080
11670
  function runContext(runId, agent, child) {
11081
11671
  return {
11082
11672
  runId,
@@ -11304,112 +11894,6 @@ function createAgentServerSecretProviders(settings, store) {
11304
11894
  };
11305
11895
  }
11306
11896
  //#endregion
11307
- //#region src/lib/agent-server/readiness.ts
11308
- /**
11309
- * Whether a runtime profile can execute on *this* machine.
11310
- *
11311
- * A profile is authored in Console against a team; whether it can run depends
11312
- * on local facts only the server knows — which provider keys are configured
11313
- * here and which runtime kinds this machine can produce.
11314
- *
11315
- * The prerequisite comparison itself is **not** reimplemented here. Run start
11316
- * calls `validateRuntimeProfilePrerequisites`, and the catalogue calls the same
11317
- * function, so the composer cannot promise a run that startup would reject for
11318
- * a reason the two evaluated differently.
11319
- */
11320
- function deriveProfileReadiness(profile, machine) {
11321
- const blockers = [];
11322
- const env = {};
11323
- for (const [name, configured] of machine.providerEnv) if (configured) env[name] = "configured";
11324
- try {
11325
- validateRuntimeProfilePrerequisites(profile, env, {
11326
- tools: machine.inventory?.tools ?? profile.requiredTools,
11327
- executables: machine.inventory?.executables ?? profile.requiredExecutables
11328
- });
11329
- } catch (error) {
11330
- if (!(error instanceof RuntimeProfilePrerequisiteError)) throw error;
11331
- for (const name of error.missingEnv) blockers.push({
11332
- code: "env_missing",
11333
- message: `${name} is not configured on this machine.`,
11334
- remedy: "Add the key under Providers, then reopen this run."
11335
- });
11336
- for (const name of error.missingTools) blockers.push({
11337
- code: "tool_missing",
11338
- message: `The runtime does not provide the tool ${name}.`,
11339
- remedy: `Use a profile whose runtime provides ${name}, or change the profile in Console.`
11340
- });
11341
- for (const name of error.missingExecutables) blockers.push({
11342
- code: "executable_missing",
11343
- message: `The runtime does not provide the executable ${name}.`,
11344
- remedy: `Use a runtime that ships ${name}, or drop the requirement in Console.`
11345
- });
11346
- }
11347
- if (!machine.runtimeKinds.has(profile.runtimeKind)) blockers.push({
11348
- code: "runtime_unregistered",
11349
- message: `Runtime kind ${profile.runtimeKind} is not available on this machine.`,
11350
- remedy: "Register the runtime under Runtimes, then reopen this run."
11351
- });
11352
- return {
11353
- ready: blockers.length === 0,
11354
- blockers
11355
- };
11356
- }
11357
- //#endregion
11358
- //#region src/lib/agent-server/catalogue.ts
11359
- async function buildCatalogue(options) {
11360
- const { agent, machine, identityDefault } = options;
11361
- const entries = await Promise.all(agent.teamIds.map(async (teamId) => {
11362
- try {
11363
- const result = await agent.readTeam(teamId);
11364
- if (result.team.id !== teamId) throw new Error("Team response mismatch");
11365
- const diaries = result.diaries.filter((diary) => diary.teamId === teamId).map(({ id, name }) => ({
11366
- id,
11367
- name
11368
- }));
11369
- return {
11370
- team: {
11371
- teamId,
11372
- teamName: result.team.name,
11373
- available: true,
11374
- blockers: [],
11375
- credential: result.credential,
11376
- diaries,
11377
- defaultDiaryId: resolveDefaultDiary(teamId, diaries, identityDefault)
11378
- },
11379
- profiles: result.profiles.filter((profile) => profile.teamId === teamId).map((profile) => ({
11380
- ...profile,
11381
- ...deriveProfileReadiness(profile, machine)
11382
- }))
11383
- };
11384
- } catch (error) {
11385
- return {
11386
- team: {
11387
- teamId,
11388
- teamName: teamId,
11389
- available: false,
11390
- blockers: [credentialBlocker(error)],
11391
- credential: agent.lastVerified(teamId),
11392
- diaries: [],
11393
- defaultDiaryId: null
11394
- },
11395
- profiles: []
11396
- };
11397
- }
11398
- }));
11399
- const teams = entries.map(({ team }) => team);
11400
- const available = teams.filter((team) => team.available);
11401
- return {
11402
- teams,
11403
- defaultTeamId: available.find((team) => team.teamId === identityDefault.teamId)?.teamId ?? available[0]?.teamId ?? null,
11404
- profiles: entries.flatMap((entry) => entry.profiles)
11405
- };
11406
- }
11407
- function resolveDefaultDiary(teamId, diaries, identityDefault) {
11408
- const bound = diaries.find((diary) => diary.id === identityDefault.diaryId);
11409
- if (bound && identityDefault.teamId === teamId) return bound.id;
11410
- return diaries.length === 1 ? diaries[0]?.id ?? null : null;
11411
- }
11412
- //#endregion
11413
11897
  //#region src/lib/agent-server/enrollment.ts
11414
11898
  /** Native callers receive metadata only; approval and storage stay local. */
11415
11899
  async function enrollIdentityTeam(options) {
@@ -11494,37 +11978,139 @@ async function enrollIdentityTeam(options) {
11494
11978
  }
11495
11979
  }
11496
11980
  //#endregion
11497
- //#region src/lib/agent-server/identity-binding.ts
11981
+ //#region src/lib/agent-server/project-bindings.ts
11982
+ /** Each check may spawn `git`; bound how many run at once. */
11983
+ var READINESS_CONCURRENCY = 4;
11984
+ var PREPARATION_COPY = {
11985
+ unsupported_strategy: "Isolated directory preparation is unavailable. Choose Work here or a Git worktree.",
11986
+ hooks_unavailable: "Preparation hooks are unavailable. Remove the hooks or choose another location."
11987
+ };
11988
+ /** Normalize with the Go-compatible grammar, reporting a rejection as a coded error. */
11989
+ function locationEndpoint(value) {
11990
+ try {
11991
+ return normalizeProjectEndpoint(value);
11992
+ } catch {
11993
+ throw new AgentServerHttpError(400, "endpoint_unsupported", "Local project locations need an HTTPS server endpoint; HTTP is allowed only on loopback");
11994
+ }
11995
+ }
11996
+ /** Machine registrations live at the base store, outside connection directories. */
11997
+ var LocalProjectBindings = class {
11998
+ root;
11999
+ path;
12000
+ apiUrl;
12001
+ /** `protectedRoots`: store and secrets directories no location may use. */
12002
+ constructor(root, apiUrl, protectedRoots = []) {
12003
+ this.protectedRoots = protectedRoots;
12004
+ this.root = resolveStoreRoot({ root });
12005
+ this.path = getProjectConfigPath({ root: this.root });
12006
+ this.apiUrl = locationEndpoint(apiUrl);
12007
+ }
12008
+ async list() {
12009
+ const bindings = (await stored(() => readProjectConfig(this.path))).bindings.filter((binding) => normalizeProjectEndpoint(binding.apiUrl) === this.apiUrl);
12010
+ const locations = [];
12011
+ for (let i = 0; i < bindings.length; i += READINESS_CONCURRENCY) locations.push(...await Promise.all(bindings.slice(i, i + READINESS_CONCURRENCY).map((binding) => this.describe(binding))));
12012
+ return locations;
12013
+ }
12014
+ /** Once `signal` aborts, nothing is written, not even after the lock is taken. */
12015
+ async save(value, options = {}) {
12016
+ const input = structuredClone(value);
12017
+ validateProjectConfig({
12018
+ version: 1,
12019
+ bindings: [input]
12020
+ });
12021
+ if (locationEndpoint(input.apiUrl) !== this.apiUrl) throw new AgentServerHttpError(400, "endpoint_mismatch", "Location belongs to another endpoint");
12022
+ if (input.source && !isAbsolute(input.source)) throw new ProjectConfigError("validation", "Select an absolute source folder");
12023
+ const location = await this.describe(input, options.signal);
12024
+ if (!location.readiness.ready) throw new AgentServerHttpError(400, location.readiness.code ?? "location_unavailable", location.readiness.message ?? "Location is unavailable");
12025
+ const binding = {
12026
+ ...input,
12027
+ apiUrl: this.apiUrl,
12028
+ ...location.effectiveSource ? { source: location.effectiveSource } : {}
12029
+ };
12030
+ options.signal?.throwIfAborted();
12031
+ await this.update((config) => {
12032
+ options.signal?.throwIfAborted();
12033
+ const index = config.bindings.findIndex((entry) => entry.name === binding.name);
12034
+ const previous = config.bindings[index];
12035
+ if (previous && normalizeProjectEndpoint(previous.apiUrl) !== this.apiUrl) throw conflict("This name belongs to another endpoint");
12036
+ if (previous && hasPreparationHooks(previous.hooks)) throw conflict("Remove preparation hooks from the configuration before editing this location");
12037
+ if (previous && (previous.teamId !== binding.teamId || previous.projectId !== binding.projectId)) throw conflict("This name belongs to another project");
12038
+ if (binding.default) {
12039
+ for (const entry of config.bindings) if (normalizeProjectEndpoint(entry.apiUrl) === this.apiUrl && entry.teamId === binding.teamId && entry.projectId === binding.projectId) entry.default = false;
12040
+ }
12041
+ if (index < 0) config.bindings.push(binding);
12042
+ else config.bindings[index] = binding;
12043
+ });
12044
+ return this.describe(binding);
12045
+ }
12046
+ async remove(name) {
12047
+ await this.update((config) => {
12048
+ const index = config.bindings.findIndex((entry) => entry.name === name && normalizeProjectEndpoint(entry.apiUrl) === this.apiUrl);
12049
+ if (index < 0) throw new AgentServerHttpError(404, "location_not_found", "Location not found");
12050
+ config.bindings.splice(index, 1);
12051
+ });
12052
+ }
12053
+ update(mutate) {
12054
+ return stored(() => updateProjectConfig(this.path, mutate));
12055
+ }
12056
+ async describe(binding, signal) {
12057
+ const { effectiveSource, readiness } = await this.readiness(binding, signal);
12058
+ return {
12059
+ ...binding,
12060
+ effectiveSource,
12061
+ readiness
12062
+ };
12063
+ }
12064
+ async readiness(binding, signal) {
12065
+ const declared = binding.source ? resolve(this.root, binding.source) : null;
12066
+ const unavailable = (code, message, effectiveSource = declared) => ({
12067
+ effectiveSource,
12068
+ readiness: {
12069
+ ready: false,
12070
+ code,
12071
+ message
12072
+ }
12073
+ });
12074
+ const blocker = preparationBlocker(binding.strategy, binding.hooks);
12075
+ if (blocker) return unavailable(blocker.code, PREPARATION_COPY[blocker.code]);
12076
+ if (binding.strategy === "none") return {
12077
+ effectiveSource: declared,
12078
+ readiness: { ready: true }
12079
+ };
12080
+ if (!declared) return unavailable("folder_missing", "Choose an existing source folder.");
12081
+ let source;
12082
+ try {
12083
+ source = await canonicalDirectory(declared);
12084
+ } catch {
12085
+ return unavailable("folder_missing", "The source folder is unavailable. Choose an existing folder.");
12086
+ }
12087
+ if (isProtectedFolder(source, this.protectedRoots)) return unavailable("folder_protected", `${PROTECTED_FOLDER_MESSAGE}.`, source);
12088
+ if (binding.strategy === "git-worktree") try {
12089
+ await validateGitSource(source, signal);
12090
+ } catch (cause) {
12091
+ if (signal?.aborted) throw cause;
12092
+ return unavailable("git_unavailable", "Choose a Git repository root with a committed revision, or choose Work here.", source);
12093
+ }
12094
+ return {
12095
+ effectiveSource: source,
12096
+ readiness: { ready: true }
12097
+ };
12098
+ }
12099
+ };
11498
12100
  /**
11499
- * The identity-wide team/diary binding, read from `<identityDir>/env`.
11500
- *
11501
- * This mirrors the Go CLI's `identityDefaultBinding` (`project_selection.go`),
11502
- * which is the fallback the CLI uses when a working directory has no
11503
- * registered project binding.
11504
- *
11505
- * The desktop cannot use the CLI's *location* bindings at all — its composer
11506
- * has no working directory to key on — but it can honour this identity-wide
11507
- * default, so an operator sees their familiar team preselected rather than an
11508
- * arbitrary first entry.
11509
- *
11510
- * Like the CLI, a half-filled pair is treated as no binding: a team without a
11511
- * diary is ignored.
12101
+ * A stored file that fails validation is server-side state, not a bad request.
12102
+ * The detail names a local path, so it goes to the logs via `cause`.
11512
12103
  */
11513
- function readIdentityDefaultBinding(identityDir) {
11514
- let contents;
12104
+ async function stored(work) {
11515
12105
  try {
11516
- contents = readFileSync(join(identityDir, "env"), "utf8");
11517
- } catch {
11518
- return {};
12106
+ return await work();
12107
+ } catch (error) {
12108
+ if (error instanceof ProjectConfigError && error.kind === "validation") throw new AgentServerHttpError(500, "config_invalid", "The project locations file is invalid. Repair or remove it, then retry.", { cause: error });
12109
+ throw error;
11519
12110
  }
11520
- const env = parseEnv(contents);
11521
- const teamId = env["MOLTNET_TEAM_ID"]?.trim();
11522
- const diaryId = env["MOLTNET_DIARY_ID"]?.trim();
11523
- if (!teamId || !diaryId) return {};
11524
- return {
11525
- teamId,
11526
- diaryId
11527
- };
12111
+ }
12112
+ function conflict(message) {
12113
+ return new AgentServerHttpError(409, "location_conflict", message);
11528
12114
  }
11529
12115
  //#endregion
11530
12116
  //#region src/lib/agent-server/protocol.ts
@@ -11626,10 +12212,83 @@ var AgentServerCatalogueProfileSchema = Type.Intersect([Type.Pick(RuntimeProfile
11626
12212
  var AgentServerCatalogueSchema = Type.Object({
11627
12213
  teams: Type.Array(schemaRef(AgentServerCatalogueTeamSchema)),
11628
12214
  defaultTeamId: Type.Union([Type.String(), Type.Null()]),
11629
- profiles: Type.Array(schemaRef(AgentServerCatalogueProfileSchema))
12215
+ profiles: Type.Array(schemaRef(AgentServerCatalogueProfileSchema)),
12216
+ projects: Type.Array(Type.Pick(ProjectResponseSchema, [
12217
+ "id",
12218
+ "teamId",
12219
+ "name",
12220
+ "description",
12221
+ "defaultDiaryId",
12222
+ "archived"
12223
+ ])),
12224
+ projectErrors: Type.Array(Type.Object({
12225
+ teamId: Type.String(),
12226
+ code: Type.Union([
12227
+ "forbidden",
12228
+ "unreachable",
12229
+ "invalid_response",
12230
+ "truncated"
12231
+ ].map((value) => Type.Literal(value))),
12232
+ message: Type.String()
12233
+ }))
11630
12234
  }, { $id: "AgentServerCatalogue" });
11631
12235
  var CatalogueQuerySchema = Type.Object({ identity: Type.String({ minLength: 1 }) });
12236
+ var AgentServerProjectLocationSchema = Type.Object({
12237
+ name: Type.String({ minLength: 1 }),
12238
+ apiUrl: Type.String(),
12239
+ teamId: Type.String({ minLength: 1 }),
12240
+ projectId: Type.String({ minLength: 1 }),
12241
+ diaryId: Type.Optional(Type.String({ minLength: 1 })),
12242
+ source: Type.Optional(Type.String({ minLength: 1 })),
12243
+ strategy: Type.Union([
12244
+ "none",
12245
+ "existing",
12246
+ "git-worktree",
12247
+ "isolated-directory"
12248
+ ].map((value) => Type.Literal(value))),
12249
+ default: Type.Optional(Type.Boolean()),
12250
+ effectiveSource: Type.Union([Type.String(), Type.Null()]),
12251
+ readiness: Type.Object({
12252
+ ready: Type.Boolean(),
12253
+ code: Type.Optional(Type.String()),
12254
+ message: Type.Optional(Type.String())
12255
+ })
12256
+ }, { $id: "AgentServerProjectLocation" });
12257
+ /** Closed: hooks and other stored fields are never writable over this route. */
12258
+ var SaveProjectLocationSchema = Type.Object({
12259
+ identity: Type.String({ minLength: 1 }),
12260
+ teamId: Type.String({ minLength: 1 }),
12261
+ projectId: Type.String({ minLength: 1 }),
12262
+ diaryId: Type.Optional(Type.String({ minLength: 1 })),
12263
+ source: Type.Optional(Type.String({ minLength: 1 })),
12264
+ strategy: Type.Union([
12265
+ "none",
12266
+ "existing",
12267
+ "git-worktree"
12268
+ ].map((value) => Type.Literal(value))),
12269
+ default: Type.Optional(Type.Boolean())
12270
+ }, { additionalProperties: false });
12271
+ var ProjectLocationParamsSchema = Type.Object({ name: Type.String({ minLength: 1 }) });
12272
+ /** Local project selection on a run request; only the native client may set these. */
12273
+ var RunProjectFields = {
12274
+ projectId: Type.Optional(Type.Union([Type.String({ minLength: 1 }), Type.Null()])),
12275
+ location: Type.Optional(Type.String({ minLength: 1 })),
12276
+ source: Type.Optional(Type.String({ minLength: 1 })),
12277
+ strategy: Type.Optional(AgentServerProjectLocationSchema.properties.strategy)
12278
+ };
12279
+ /** Names gated to the native client; derived so a new field is gated automatically. */
12280
+ var NATIVE_RUN_FIELDS = Object.keys(RunProjectFields);
12281
+ /** What a run resolved to; the record's top-level fields stay as requested. */
12282
+ var RunWorkspaceSchema = Type.Object({
12283
+ projectId: Type.Union([Type.String(), Type.Null()]),
12284
+ location: Type.Optional(Type.String()),
12285
+ diaryId: Type.Optional(Type.String()),
12286
+ source: Type.Optional(Type.String()),
12287
+ strategy: Type.Union([AgentServerProjectLocationSchema.properties.strategy, Type.Literal(PROFILE_DEFAULT_STRATEGY)])
12288
+ });
11632
12289
  var AgentServerRunRecordSchema = Type.Object({
12290
+ ...RunProjectFields,
12291
+ workspace: Type.Optional(RunWorkspaceSchema),
11633
12292
  id: Type.String(),
11634
12293
  agent: Type.String(),
11635
12294
  teamId: Type.String(),
@@ -11709,6 +12368,7 @@ var PutProviderSchema = Type.Object({
11709
12368
  });
11710
12369
  var DiscoverModelsSchema = Type.Object({ models: ProviderModelList }, { $id: "DiscoveredModels" });
11711
12370
  var StartRunSchema = Type.Object({
12371
+ ...RunProjectFields,
11712
12372
  agent: Type.String(),
11713
12373
  teamId: Type.String(),
11714
12374
  diaryId: Type.Optional(Type.String()),
@@ -11725,6 +12385,7 @@ var LogStreamSchema = Type.String({
11725
12385
  contentMediaType: "text/event-stream"
11726
12386
  });
11727
12387
  var AGENT_SERVER_SCHEMAS = [
12388
+ AgentServerProjectLocationSchema,
11728
12389
  AgentServerHealthSchema,
11729
12390
  AgentServerProblemSchema,
11730
12391
  AgentServerAgentSchema,
@@ -11747,6 +12408,39 @@ var AGENT_SERVER_SCHEMAS = [
11747
12408
  var localControlSecurity = [{ agentServerToken: [] }];
11748
12409
  var problemResponse = { default: schemaRef(AgentServerProblemSchema) };
11749
12410
  var AgentServerRouteSchemas = {
12411
+ listProjectLocations: {
12412
+ operationId: "listNativeProjectLocations",
12413
+ tags: ["native-projects"],
12414
+ security: localControlSecurity,
12415
+ description: "Requires the Desktop native grant; browser authorization is insufficient.",
12416
+ response: {
12417
+ 200: Type.Object({ locations: Type.Array(schemaRef(AgentServerProjectLocationSchema)) }),
12418
+ ...problemResponse
12419
+ }
12420
+ },
12421
+ saveProjectLocation: {
12422
+ operationId: "saveNativeProjectLocation",
12423
+ tags: ["native-projects"],
12424
+ security: localControlSecurity,
12425
+ description: "Creates or replaces the named location. Requires the Desktop native grant.",
12426
+ params: ProjectLocationParamsSchema,
12427
+ body: SaveProjectLocationSchema,
12428
+ response: {
12429
+ 200: schemaRef(AgentServerProjectLocationSchema),
12430
+ ...problemResponse
12431
+ }
12432
+ },
12433
+ removeProjectLocation: {
12434
+ operationId: "removeNativeProjectLocation",
12435
+ tags: ["native-projects"],
12436
+ security: localControlSecurity,
12437
+ description: "Removes the registration only; the folder is left untouched. Requires the Desktop native grant.",
12438
+ params: ProjectLocationParamsSchema,
12439
+ response: {
12440
+ 200: Type.Object({ removed: Type.Boolean() }),
12441
+ ...problemResponse
12442
+ }
12443
+ },
11750
12444
  health: {
11751
12445
  operationId: "getAgentServerHealth",
11752
12446
  tags: ["system"],
@@ -11920,6 +12614,7 @@ var AgentServerRouteSchemas = {
11920
12614
  operationId: "startAgentServerRun",
11921
12615
  tags: ["runs"],
11922
12616
  security: localControlSecurity,
12617
+ description: "projectId (other than null), location, source and strategy are native-only: other origins receive 403 native_required. A request naming none of them runs without project workspace wiring. The record keeps these fields as requested; resolved values are in `workspace`.",
11923
12618
  body: StartRunSchema,
11924
12619
  response: {
11925
12620
  201: schemaRef(AgentServerRunSchema),
@@ -11954,8 +12649,8 @@ var AgentServerRouteSchemas = {
11954
12649
  * loopback-companion security profile (#2066): loopback Host enforcement,
11955
12650
  * exact-origin CORS, Fetch-Metadata guards, strict JSON parsing.
11956
12651
  *
11957
- * Control routes require a native process grant or an OAuth token bound to
11958
- * the native operator and this server instance. Origin checks apply to both.
12652
+ * Control routes require the process-scoped native Desktop grant. OAuth is
12653
+ * used only for native operator sign-in and credential provisioning.
11959
12654
  */
11960
12655
  var AGENT_SERVER_TOKEN_HEADER = "x-moltnet-agent-server-token";
11961
12656
  var BODY_LIMIT = 64 * 1024;
@@ -12014,14 +12709,6 @@ async function readAgentServerLogDelta(handle, state, limit = LOG_READ_LIMIT_BYT
12014
12709
  omitted
12015
12710
  };
12016
12711
  }
12017
- var AgentServerHttpError = class extends Error {
12018
- name = "AgentServerHttpError";
12019
- constructor(statusCode, code, message) {
12020
- super(message);
12021
- this.statusCode = statusCode;
12022
- this.code = code;
12023
- }
12024
- };
12025
12712
  function requireBody(request) {
12026
12713
  const body = request.body;
12027
12714
  if (typeof body !== "object" || body === null || Array.isArray(body)) throw new AgentServerHttpError(400, "invalid_body", "JSON object body required");
@@ -12107,22 +12794,6 @@ function buildAgentServer(input) {
12107
12794
  isOriginAllowed: (origin) => origin === "moltnet-agent-desktop://native" || origin === options.selfOrigin || browserOrigins.has(origin)
12108
12795
  });
12109
12796
  }
12110
- let verificationWindow = Date.now();
12111
- let verifications = 0;
12112
- const browserVerification = /* @__PURE__ */ new WeakMap();
12113
- function verifyBrowser(request, token) {
12114
- const previous = browserVerification.get(request);
12115
- if (previous) return previous;
12116
- if (Date.now() - verificationWindow >= RATE_LIMIT_WINDOW_MS) {
12117
- verificationWindow = Date.now();
12118
- verifications = 0;
12119
- }
12120
- if (++verifications > RATE_LIMIT_MAX) throw new AgentServerHttpError(429, "rate_limited", "Too many authorization attempts");
12121
- if (!oauth) throw new AgentServerHttpError(503, "oauth_unavailable", "Local OAuth is not configured");
12122
- const pending = oauth.verifyBrowser(token);
12123
- browserVerification.set(request, pending);
12124
- return pending;
12125
- }
12126
12797
  function hasValidNativeGrant(origin, token) {
12127
12798
  if (origin !== "moltnet-agent-desktop://native" || typeof token !== "string" || token.length === 0) return false;
12128
12799
  try {
@@ -12153,56 +12824,20 @@ function buildAgentServer(input) {
12153
12824
  max: options.rateLimitMax ?? RATE_LIMIT_MAX,
12154
12825
  timeWindow: RATE_LIMIT_WINDOW_MS,
12155
12826
  errorResponseBuilder: () => new AgentServerHttpError(429, "rate_limited", "Too many requests"),
12156
- keyGenerator: async (request) => {
12827
+ keyGenerator: (request) => {
12157
12828
  const origin = request.headers.origin;
12158
12829
  if (!isConfiguredOrigin(origin, options)) return `ip:${request.ip}`;
12159
12830
  const presented = request.headers[AGENT_SERVER_TOKEN_HEADER];
12160
- let authenticated = false;
12161
- if (typeof presented === "string" && presented.length > 0) try {
12162
- if (origin === "moltnet-agent-desktop://native") authenticated = hasValidNativeGrant(origin, presented);
12163
- else {
12164
- if (!oauth) return `unauth:${origin}:${request.ip}`;
12165
- await verifyBrowser(request, presented);
12166
- authenticated = true;
12167
- }
12168
- } catch (error) {
12169
- if (error instanceof AgentServerHttpError && error.statusCode === 429) throw error;
12170
- if (origin === "moltnet-agent-desktop://native") throw error;
12171
- }
12172
- return authenticated ? `origin:${origin}` : `unauth:${origin}:${request.ip}`;
12831
+ return typeof presented === "string" && presented.length > 0 && hasValidNativeGrant(origin, presented) ? `origin:${origin}` : `unauth:${origin}:${request.ip}`;
12173
12832
  }
12174
12833
  });
12175
12834
  const requireAuthorizedOrigin = async (request) => {
12176
12835
  if (restartRequired) throw new AgentServerHttpError(409, "restart_required", "Restart the Agent Server to apply connection settings");
12177
12836
  const origin = requireOriginHeader(request.headers);
12178
12837
  const token = request.headers[AGENT_SERVER_TOKEN_HEADER];
12179
- if (typeof token !== "string" || token.length === 0) throw new AgentServerHttpError(401, "authorization_required", "Local control token is required");
12180
- if (origin === "moltnet-agent-desktop://native") requireNativeGrant(request);
12181
- else try {
12182
- if (!oauth) throw new AgentServerHttpError(503, "oauth_unavailable", "Local OAuth is not configured");
12183
- const admission = browserVerification.get(request);
12184
- browserVerification.delete(request);
12185
- await (admission ?? oauth.verifyBrowser(token));
12186
- } catch (error) {
12187
- if (error instanceof AgentServerHttpError) throw error;
12188
- const code = error && typeof error === "object" && "code" in error ? error.code : void 0;
12189
- const rejected = error instanceof InvalidOperatorGrantError || typeof code === "string" && [
12190
- "ERR_JWT_EXPIRED",
12191
- "ERR_JWT_CLAIM_VALIDATION_FAILED",
12192
- "ERR_JWS_SIGNATURE_VERIFICATION_FAILED",
12193
- "ERR_JWS_INVALID",
12194
- "ERR_JWT_INVALID",
12195
- "ERR_JOSE_ALG_NOT_ALLOWED",
12196
- "ERR_JWKS_NO_MATCHING_KEY"
12197
- ].includes(code);
12198
- request.log.warn({
12199
- stage: "local-control-authorization",
12200
- outcome: rejected ? "rejected" : "unavailable",
12201
- code: typeof code === "string" ? code : void 0
12202
- }, "Local control authorization failed");
12203
- if (!rejected) throw new AgentServerHttpError(503, "authorization_unavailable", "Local authorization is unavailable. Check Server settings or retry shortly.");
12204
- throw new AgentServerHttpError(401, "authorization_required", "Sign in to authorize local control");
12205
- }
12838
+ if (typeof token !== "string" || token.length === 0) throw new AgentServerHttpError(401, "authorization_required", "Native authorization is required");
12839
+ if (origin !== "moltnet-agent-desktop://native") throw new AgentServerHttpError(403, "native_required", "Native Desktop authorization required");
12840
+ requireNativeGrant(request);
12206
12841
  return origin;
12207
12842
  };
12208
12843
  app.after(() => {
@@ -12211,13 +12846,12 @@ function buildAgentServer(input) {
12211
12846
  });
12212
12847
  app.get("/health", { schema: AgentServerRouteSchemas.health }, async () => ({ status: "ok" }));
12213
12848
  app.get("/v1/native/connection-settings", { schema: { hide: true } }, async (request) => {
12214
- if (await requireAuthorizedOrigin(request) !== "moltnet-agent-desktop://native" || !options.connectionSettings) throw new AgentServerHttpError(403, "native_required", "Native administration required");
12215
- return options.connectionSettings.view();
12849
+ return (await requireNativeOrigin(requireAuthorizedOrigin, request, options.connectionSettings)).view();
12216
12850
  });
12217
12851
  app.post("/v1/native/connection-settings", { schema: { hide: true } }, async (request) => {
12218
- if (await requireAuthorizedOrigin(request) !== "moltnet-agent-desktop://native" || !options.connectionSettings) throw new AgentServerHttpError(403, "native_required", "Native administration required");
12852
+ const store = await requireNativeOrigin(requireAuthorizedOrigin, request, options.connectionSettings);
12219
12853
  try {
12220
- const settings = options.runs.prepareServerRestart(() => options.connectionSettings.save(request.body));
12854
+ const settings = options.runs.prepareServerRestart(() => store.save(request.body));
12221
12855
  oauth?.cancel();
12222
12856
  restartRequired = true;
12223
12857
  return settings;
@@ -12225,40 +12859,6 @@ function buildAgentServer(input) {
12225
12859
  throw new AgentServerHttpError(400, "invalid_connection_settings", error instanceof Error ? error.message : "Invalid connection settings");
12226
12860
  }
12227
12861
  });
12228
- app.get("/oauth/metadata", { schema: {
12229
- operationId: "getAgentServerOAuthMetadata",
12230
- tags: ["operator"],
12231
- response: { 200: {
12232
- type: "object",
12233
- required: [
12234
- "protocolVersion",
12235
- "instance",
12236
- "issuer",
12237
- "authorizationUrl",
12238
- "tokenUrl",
12239
- "clientId",
12240
- "operatorConfigured"
12241
- ],
12242
- properties: {
12243
- protocolVersion: {
12244
- type: "integer",
12245
- const: OPERATOR_OAUTH.protocolVersion
12246
- },
12247
- instance: {
12248
- type: "string",
12249
- format: "uuid"
12250
- },
12251
- issuer: { type: "string" },
12252
- authorizationUrl: { type: "string" },
12253
- tokenUrl: { type: "string" },
12254
- clientId: { type: "string" },
12255
- operatorConfigured: { type: "boolean" }
12256
- }
12257
- } }
12258
- } }, async () => {
12259
- if (!oauth) throw new AgentServerHttpError(503, "oauth_unavailable", "Local OAuth is not configured");
12260
- return oauth.metadata();
12261
- });
12262
12862
  app.post("/v1/operator/sign-in", { schema: {
12263
12863
  operationId: "signInAgentServerOperator",
12264
12864
  response: { 200: {
@@ -12267,10 +12867,12 @@ function buildAgentServer(input) {
12267
12867
  required: ["state"]
12268
12868
  } }
12269
12869
  } }, async (request) => {
12270
- if (await requireAuthorizedOrigin(request) !== "moltnet-agent-desktop://native" || !oauth) throw new AgentServerHttpError(403, "native_required", "Native administration required");
12271
- await oauth.authorize(void 0, requestOperationSignal(request, options.shutdownSignal));
12870
+ await (await requireNativeOrigin(requireAuthorizedOrigin, request, oauth)).authorize(void 0, requestOperationSignal(request, options.shutdownSignal));
12272
12871
  return { state: "authorized" };
12273
12872
  });
12873
+ app.get("/v1/native/operator", { schema: { hide: true } }, async (request) => {
12874
+ return { operatorConfigured: (await requireNativeOrigin(requireAuthorizedOrigin, request, oauth)).operatorConfigured() };
12875
+ });
12274
12876
  app.post("/v1/operator/cancel", { schema: {
12275
12877
  operationId: "cancelAgentServerOperatorApproval",
12276
12878
  tags: ["operator"],
@@ -12284,8 +12886,7 @@ function buildAgentServer(input) {
12284
12886
  required: ["state"]
12285
12887
  } }
12286
12888
  } }, async (request) => {
12287
- if (await requireAuthorizedOrigin(request) !== "moltnet-agent-desktop://native" || !oauth) throw new AgentServerHttpError(403, "native_required", "Native administration required");
12288
- oauth.cancel();
12889
+ (await requireNativeOrigin(requireAuthorizedOrigin, request, oauth)).cancel();
12289
12890
  return { state: "cancelled" };
12290
12891
  });
12291
12892
  app.delete("/v1/operator", { schema: {
@@ -12301,8 +12902,7 @@ function buildAgentServer(input) {
12301
12902
  required: ["state"]
12302
12903
  } }
12303
12904
  } }, async (request) => {
12304
- if (await requireAuthorizedOrigin(request) !== "moltnet-agent-desktop://native" || !oauth) throw new AgentServerHttpError(403, "native_required", "Native administration required");
12305
- oauth.removeOperator();
12905
+ (await requireNativeOrigin(requireAuthorizedOrigin, request, oauth)).removeOperator();
12306
12906
  return { state: "removed" };
12307
12907
  });
12308
12908
  registerStatusRoute(app, options, requireAuthorizedOrigin);
@@ -12311,6 +12911,7 @@ function buildAgentServer(input) {
12311
12911
  registerSubscriptionRoutes(app, options, requireAuthorizedOrigin);
12312
12912
  registerRunRoutes(app, options, requireAuthorizedOrigin);
12313
12913
  registerCatalogueRoute(app, options, requireAuthorizedOrigin);
12914
+ registerProjectLocationRoutes(app, options, requireAuthorizedOrigin);
12314
12915
  });
12315
12916
  app.addHook("preClose", async () => {
12316
12917
  options.operatorOAuth?.cancel();
@@ -12337,6 +12938,103 @@ function buildAgentServer(input) {
12337
12938
  });
12338
12939
  return app;
12339
12940
  }
12941
+ async function requireNativeOrigin(authorize, request, ...resource) {
12942
+ if (await authorize(request) !== "moltnet-agent-desktop://native" || resource.length > 0 && resource[0] === void 0) throw new AgentServerHttpError(403, "native_required", "Native administration required");
12943
+ return resource[0];
12944
+ }
12945
+ function registerProjectLocationRoutes(app, options, authorize) {
12946
+ let locations;
12947
+ const getLocations = () => {
12948
+ const root = options.connectionSettings?.root;
12949
+ if (!root) throw new AgentServerHttpError(503, "locations_unavailable", "Project locations need the Desktop connection store");
12950
+ locations ??= new LocalProjectBindings(root, options.defaultApiUrl, [
12951
+ root,
12952
+ options.store.root,
12953
+ options.store.secretsDir
12954
+ ]);
12955
+ return locations;
12956
+ };
12957
+ const requireNativeRequest = async (request) => {
12958
+ await requireNativeOrigin(authorize, request);
12959
+ if (request.validationError) throw new AgentServerHttpError(400, "invalid_location", "Check the project location fields");
12960
+ };
12961
+ app.get("/v1/native/project-locations", { schema: AgentServerRouteSchemas.listProjectLocations }, async (request) => {
12962
+ await requireNativeRequest(request);
12963
+ return { locations: await getLocations().list() };
12964
+ });
12965
+ const saveFields = new Set(Object.keys(AgentServerRouteSchemas.saveProjectLocation.body.properties));
12966
+ app.put("/v1/native/project-locations/:name", {
12967
+ schema: AgentServerRouteSchemas.saveProjectLocation,
12968
+ attachValidation: true,
12969
+ preValidation: async (request) => {
12970
+ await requireNativeOrigin(authorize, request);
12971
+ const body = request.body;
12972
+ if (body && typeof body === "object" && Object.keys(body).some((key) => !saveFields.has(key))) throw new AgentServerHttpError(400, "invalid_location", "Check the project location fields");
12973
+ }
12974
+ }, async (request) => {
12975
+ await requireNativeRequest(request);
12976
+ const bindings = getLocations();
12977
+ const { name } = request.params;
12978
+ const { identity, ...location } = requireBody(request);
12979
+ const alias = identity.trim();
12980
+ requireActivation(options.store, alias);
12981
+ const { config } = await loadAgentActivation(options.store, alias);
12982
+ if (locationEndpoint(config.endpoints.api) !== bindings.apiUrl) throw new AgentServerHttpError(400, "endpoint_mismatch", "Choose an identity for the current server endpoint");
12983
+ const signal = AbortSignal.any([requestOperationSignal(request, options.shutdownSignal), AbortSignal.timeout(options.projectSaveTimeoutMs ?? 1e4)]);
12984
+ await verifyLocationTarget(options, alias, location, request.log, signal);
12985
+ try {
12986
+ return await bindings.save({
12987
+ ...location,
12988
+ name,
12989
+ apiUrl: bindings.apiUrl
12990
+ }, { signal });
12991
+ } catch (error) {
12992
+ if (signal.aborted) throw checkUnavailable(error, true);
12993
+ throw error;
12994
+ }
12995
+ });
12996
+ app.delete("/v1/native/project-locations/:name", {
12997
+ schema: AgentServerRouteSchemas.removeProjectLocation,
12998
+ attachValidation: true
12999
+ }, async (request) => {
13000
+ await requireNativeRequest(request);
13001
+ await getLocations().remove(request.params.name);
13002
+ return { removed: true };
13003
+ });
13004
+ }
13005
+ /**
13006
+ * A location save checks only its target team: `readTeam` verifies the team
13007
+ * credential and returns its diaries, then the shared check reads the project.
13008
+ */
13009
+ async function verifyLocationTarget(options, alias, target, logger, signal) {
13010
+ const agent = await catalogueAgent(options, alias);
13011
+ if (!agent.teamIds.includes(target.teamId)) throw projectUnavailable();
13012
+ let diaries = [];
13013
+ await verifyProjectTarget({
13014
+ readProject: async (teamId, projectId, readSignal) => {
13015
+ const team = await agent.readTeam(teamId, readSignal);
13016
+ if (team.team.id !== teamId) throw new Error("Team response mismatch");
13017
+ diaries = team.diaries;
13018
+ return agent.readProject(teamId, projectId, readSignal);
13019
+ },
13020
+ readDiary: async (teamId, diaryId) => diaries.find((diary) => diary.id === diaryId && diary.teamId === teamId) ?? null
13021
+ }, target, {
13022
+ signal,
13023
+ logger
13024
+ });
13025
+ }
13026
+ async function catalogueAgent(options, alias) {
13027
+ return options.catalogueAgentFor ? options.catalogueAgentFor(alias) : defaultCatalogueAgent(options, alias);
13028
+ }
13029
+ async function readIdentityCatalogue(options, alias, logger) {
13030
+ requireActivation(options.store, alias);
13031
+ return buildCatalogue({
13032
+ agent: await catalogueAgent(options, alias),
13033
+ machine: machineCapabilities(options),
13034
+ identityDefault: readIdentityDefaultBinding(options.store.identityDir(alias)),
13035
+ logger
13036
+ });
13037
+ }
12340
13038
  function registerCatalogueRoute(app, options, requireAuthorizedOrigin) {
12341
13039
  app.get("/v1/catalogue", {
12342
13040
  schema: AgentServerRouteSchemas.catalogue,
@@ -12345,34 +13043,42 @@ function registerCatalogueRoute(app, options, requireAuthorizedOrigin) {
12345
13043
  await requireAuthorizedOrigin(request);
12346
13044
  const { identity } = request.query ?? {};
12347
13045
  if (!identity || identity.trim().length === 0) throw new AgentServerHttpError(400, "invalid_query", "\"identity\" is required");
12348
- const alias = identity.trim();
12349
- requireActivation(options.store, alias);
12350
- return buildCatalogue({
12351
- agent: await (options.catalogueAgentFor ? options.catalogueAgentFor(alias) : defaultCatalogueAgent(options, alias)),
12352
- machine: machineCapabilities(options),
12353
- identityDefault: readIdentityDefaultBinding(options.store.identityDir(alias))
12354
- });
13046
+ return readIdentityCatalogue(options, identity.trim(), request.log);
12355
13047
  });
12356
13048
  }
12357
13049
  /** Resolve and verify each indexed team independently with its exact key. */
12358
13050
  async function defaultCatalogueAgent(options, alias) {
12359
13051
  const { config } = await loadAgentActivation(options.store, alias);
13052
+ const clients = /* @__PURE__ */ new Map();
13053
+ const verifiedClient = (teamId) => {
13054
+ const client = clients.get(teamId);
13055
+ if (!client) throw new Error("Team must be verified before project discovery");
13056
+ return client;
13057
+ };
12360
13058
  return {
12361
13059
  teamIds: Object.keys(config.agent_key_refs ?? {}),
12362
13060
  lastVerified: (teamId) => requireActivation(options.store, alias).credentialHealth?.[teamId],
12363
- readTeam: async (teamId) => {
12364
- const { client, metadata } = requireCredentialSnapshot(await verifyTeamActivation(options.store, alias, options.secretProviders, options.externalSecretProviders, void 0, options.shutdownSignal, teamId));
13061
+ readTeam: async (teamId, signal) => {
13062
+ const activated = await verifyTeamActivation(options.store, alias, options.secretProviders, options.externalSecretProviders, void 0, signal && options.shutdownSignal ? AbortSignal.any([signal, options.shutdownSignal]) : signal ?? options.shutdownSignal, teamId);
13063
+ signal?.throwIfAborted();
13064
+ const { client, metadata } = requireCredentialSnapshot(activated);
12365
13065
  const [team, diaries, profiles] = await Promise.all([
12366
13066
  client.teams.get(teamId),
12367
13067
  client.diaries.list(),
12368
13068
  client.runtimeProfiles.list({ teamId })
12369
13069
  ]);
13070
+ clients.set(teamId, client);
12370
13071
  return {
12371
13072
  team,
12372
13073
  diaries: diaries.items,
12373
13074
  profiles: profiles.items,
12374
13075
  credential: metadata
12375
13076
  };
13077
+ },
13078
+ readProjects: async (teamId) => readCatalogueProjects(verifiedClient(teamId).projects, teamId),
13079
+ readProject: async (teamId, projectId, signal) => {
13080
+ signal?.throwIfAborted();
13081
+ return readCatalogueProject(verifiedClient(teamId).projects, teamId, projectId);
12376
13082
  }
12377
13083
  };
12378
13084
  }
@@ -12395,7 +13101,7 @@ function machineCapabilities(options) {
12395
13101
  function registerStatusRoute(app, options, requireAuthorizedOrigin) {
12396
13102
  const { store, runs } = options;
12397
13103
  app.get("/v1/status", { schema: AgentServerRouteSchemas.status }, async (request) => {
12398
- await requireAuthorizedOrigin(request);
13104
+ const origin = await requireAuthorizedOrigin(request);
12399
13105
  const selected = selectedIdentity(store, options.activeIdentity);
12400
13106
  return {
12401
13107
  version: options.version,
@@ -12405,7 +13111,7 @@ function registerStatusRoute(app, options, requireAuthorizedOrigin) {
12405
13111
  identities: identityViews(store),
12406
13112
  ...selected ? { selectedIdentity: selected } : {},
12407
13113
  providers: options.providers.list(),
12408
- runs: await runViews(runs),
13114
+ runs: await runViews(runs, origin),
12409
13115
  runtimeSettings: options.runtimeSettings ?? DEFAULT_LOCAL_OPERATIONAL_SETTINGS
12410
13116
  };
12411
13117
  });
@@ -12530,11 +13236,30 @@ function registerProviderRoutes(app, options, requireAuthorizedOrigin) {
12530
13236
  return reply.code(204).send(null);
12531
13237
  });
12532
13238
  }
12533
- async function runViews(runs) {
12534
- return (await runs.listAsync(RUN_HISTORY_LIMIT)).map((record) => ({
12535
- ...record,
12536
- active: runs.isActive(record.id)
12537
- }));
13239
+ async function runViews(runs, origin) {
13240
+ return (await runs.listAsync(RUN_HISTORY_LIMIT)).map((record) => runView(record, runs.isActive(record.id), origin));
13241
+ }
13242
+ /**
13243
+ * Local folders are native-only, as project locations are: other origins see
13244
+ * the location name and project, never the path. The snapshot path is
13245
+ * daemon-internal for every origin.
13246
+ */
13247
+ function runView(record, active, origin) {
13248
+ const native = origin === NATIVE_CLIENT_ORIGIN;
13249
+ const { source, workspace, ...rest } = record;
13250
+ const view = {
13251
+ ...rest,
13252
+ ...native && source !== void 0 ? { source } : {},
13253
+ active
13254
+ };
13255
+ if (workspace) {
13256
+ const { configPath: _configPath, source: resolvedSource, ...shared } = workspace;
13257
+ view.workspace = {
13258
+ ...shared,
13259
+ ...native && resolvedSource !== void 0 ? { source: resolvedSource } : {}
13260
+ };
13261
+ }
13262
+ return view;
12538
13263
  }
12539
13264
  function registerSubscriptionRoutes(app, options, requireAuthorizedOrigin) {
12540
13265
  app.get("/v1/subscriptions", { schema: AgentServerRouteSchemas.listSubscriptions }, async (request) => {
@@ -12561,17 +13286,22 @@ function registerSubscriptionRoutes(app, options, requireAuthorizedOrigin) {
12561
13286
  function registerRunRoutes(app, options, requireAuthorizedOrigin) {
12562
13287
  const { runs } = options;
12563
13288
  app.get("/v1/runs", { schema: AgentServerRouteSchemas.listRuns }, async (request) => {
12564
- await requireAuthorizedOrigin(request);
12565
- return runViews(runs);
13289
+ return runViews(runs, await requireAuthorizedOrigin(request));
12566
13290
  });
12567
13291
  app.post("/v1/runs", {
12568
13292
  schema: AgentServerRouteSchemas.startRun,
12569
13293
  attachValidation: true
12570
13294
  }, async (request, reply) => {
12571
- await requireAuthorizedOrigin(request);
13295
+ const origin = await requireAuthorizedOrigin(request);
12572
13296
  const body = requireBody(request);
13297
+ if (NATIVE_RUN_FIELDS.some((key) => body[key] !== void 0 && body[key] !== null)) await requireNativeOrigin(async () => origin, request);
13298
+ if (request.validationError) throw new AgentServerHttpError(400, "invalid_spec", `Check the run fields: ${request.validationError.message}`);
12573
13299
  const diaryId = optionalString(body, "diaryId");
12574
13300
  const record = await runs.start({
13301
+ ...body.projectId === null ? { projectId: null } : body.projectId !== void 0 ? { projectId: requireString(body, "projectId") } : {},
13302
+ ...body.location !== void 0 ? { location: requireString(body, "location") } : {},
13303
+ ...body.source !== void 0 ? { source: requireString(body, "source") } : {},
13304
+ ...body.strategy !== void 0 ? { strategy: requireString(body, "strategy") } : {},
12575
13305
  agent: requireString(body, "agent"),
12576
13306
  teamId: requireString(body, "teamId"),
12577
13307
  ...diaryId ? { diaryId } : {},
@@ -12579,15 +13309,13 @@ function registerRunRoutes(app, options, requireAuthorizedOrigin) {
12579
13309
  taskTypes: stringArray(body, "taskTypes"),
12580
13310
  mode: requireString(body, "mode")
12581
13311
  }, requestOperationSignal(request, options.shutdownSignal));
12582
- return reply.code(201).send({
12583
- ...record,
12584
- active: runs.isActive(record.id)
12585
- });
13312
+ return reply.code(201).send(runView(record, runs.isActive(record.id), origin));
12586
13313
  });
12587
13314
  app.delete("/v1/runs/:runId", { schema: AgentServerRouteSchemas.stopRun }, async (request) => {
12588
- await requireAuthorizedOrigin(request);
13315
+ const origin = await requireAuthorizedOrigin(request);
12589
13316
  const { runId } = request.params;
12590
- return runs.stop(runId);
13317
+ const record = runs.stop(runId);
13318
+ return runView(record, runs.isActive(record.id), origin);
12591
13319
  });
12592
13320
  registerRunLogRoute(app, options, requireAuthorizedOrigin);
12593
13321
  }
@@ -12614,9 +13342,10 @@ function registerRunLogRoute(app, options, requireAuthorizedOrigin) {
12614
13342
  } }
12615
13343
  } }
12616
13344
  } }, async (request) => {
12617
- await requireAuthorizedOrigin(request);
13345
+ const origin = await requireAuthorizedOrigin(request);
12618
13346
  const { runId } = request.params;
12619
13347
  const record = runs.status(runId);
13348
+ const redact = localPathRedactor(record, origin, options);
12620
13349
  const handle = await open(store.resolveRunLogPath(record.id), constants.O_RDONLY | constants.O_NOFOLLOW);
12621
13350
  try {
12622
13351
  const state = {
@@ -12624,20 +13353,22 @@ function registerRunLogRoute(app, options, requireAuthorizedOrigin) {
12624
13353
  fragment: ""
12625
13354
  };
12626
13355
  const { lines, omitted } = await readAgentServerLogDelta(handle, state);
13356
+ const native = origin === NATIVE_CLIENT_ORIGIN;
12627
13357
  return { lines: [
12628
13358
  ...omitted ? ["[older log output omitted]"] : [],
12629
- ...lines,
12630
- ...state.fragment ? [state.fragment] : []
12631
- ] };
13359
+ ...omitted && !native ? lines.slice(1) : lines,
13360
+ ...state.fragment && (native || !runs.isActive(record.id)) ? [state.fragment] : []
13361
+ ].map(redact) };
12632
13362
  } finally {
12633
13363
  await handle.close();
12634
13364
  }
12635
13365
  });
12636
13366
  let openStreams = 0;
12637
13367
  app.get("/v1/runs/:runId/logs", { schema: AgentServerRouteSchemas.streamRunLogs }, async (request, reply) => {
12638
- await requireAuthorizedOrigin(request);
13368
+ const origin = await requireAuthorizedOrigin(request);
12639
13369
  const { runId } = request.params;
12640
13370
  const record = runs.status(runId);
13371
+ const redact = localPathRedactor(record, origin, options);
12641
13372
  store.resolveRunLogPath(record.id);
12642
13373
  if (openStreams >= MAX_LOG_STREAMS) throw new AgentServerHttpError(429, "rate_limited", "Too many concurrent log streams");
12643
13374
  openStreams += 1;
@@ -12678,12 +13409,12 @@ function registerRunLogRoute(app, options, requireAuthorizedOrigin) {
12678
13409
  });
12679
13410
  };
12680
13411
  const push = async () => {
12681
- if (request.headers.origin !== "moltnet-agent-desktop://native") await requireAuthorizedOrigin(request);
12682
13412
  const handle = await open(store.resolveRunLogPath(record.id), constants.O_RDONLY | constants.O_NOFOLLOW);
12683
13413
  try {
12684
13414
  const { lines, omitted } = await readAgentServerLogDelta(handle, readState);
12685
13415
  if (omitted) await writeData("[older log output omitted]");
12686
- for (const line of lines) await writeData(line);
13416
+ const complete = omitted && origin !== "moltnet-agent-desktop://native" ? lines.slice(1) : lines;
13417
+ for (const line of complete) await writeData(redact(line));
12687
13418
  } finally {
12688
13419
  await handle.close();
12689
13420
  }
@@ -12718,6 +13449,43 @@ function registerRunLogRoute(app, options, requireAuthorizedOrigin) {
12718
13449
  return reply;
12719
13450
  });
12720
13451
  }
13452
+ /**
13453
+ * Worker logs name local folders (the chosen source, state and HOME under the
13454
+ * store). Non-native origins get them replaced, as `runView` does for records.
13455
+ * The user's home directory is included so any other path under it cannot
13456
+ * reveal the OS account name.
13457
+ */
13458
+ /**
13459
+ * A service account's home can be `/` or `/root`; replacing it would rewrite
13460
+ * every slash in the log, so only a home with two or more segments counts.
13461
+ */
13462
+ function redactableHome() {
13463
+ const home = homedir();
13464
+ return home.split(/[\\/]/u).filter(Boolean).length >= 2 ? home : void 0;
13465
+ }
13466
+ function localPathRedactor(record, origin, options) {
13467
+ if (origin === "moltnet-agent-desktop://native") return (line) => line;
13468
+ const paths = /* @__PURE__ */ new Set();
13469
+ for (const path of [
13470
+ record.source,
13471
+ record.workspace?.source,
13472
+ options.store.root,
13473
+ options.connectionSettings?.root,
13474
+ redactableHome()
13475
+ ]) {
13476
+ if (!path) continue;
13477
+ const forms = [path];
13478
+ try {
13479
+ forms.push(realpathSync.native(path));
13480
+ } catch {}
13481
+ for (const form of forms) {
13482
+ paths.add(form);
13483
+ paths.add(JSON.stringify(form).slice(1, -1));
13484
+ }
13485
+ }
13486
+ const ordered = [...paths].sort((a, b) => b.length - a.length);
13487
+ return (line) => ordered.reduce((text, path) => text.split(path).join("<local path>"), line);
13488
+ }
12721
13489
  function corsHeadersFor(request, options) {
12722
13490
  const origin = request.headers.origin;
12723
13491
  if (isConfiguredOrigin(origin, options)) return {
@@ -12727,9 +13495,26 @@ function corsHeadersFor(request, options) {
12727
13495
  return {};
12728
13496
  }
12729
13497
  function isConfiguredOrigin(origin, options) {
12730
- return typeof origin === "string" && (origin === "moltnet-agent-desktop://native" || options.allowedOrigins.includes(origin) || origin === options.selfOrigin);
13498
+ return typeof origin === "string" && (origin === "moltnet-agent-desktop://native" || !options.nativeOnly && (options.allowedOrigins.includes(origin) || origin === options.selfOrigin));
12731
13499
  }
12732
13500
  function normalizeAgentServerError(error) {
13501
+ if (error instanceof ProjectConfigError) {
13502
+ if (error.kind === "version") return {
13503
+ statusCode: 409,
13504
+ code: "config_version",
13505
+ message: "The project locations file was written by a newer MoltNet. Update this app."
13506
+ };
13507
+ if (error.kind === "io") return {
13508
+ statusCode: 500,
13509
+ code: "config_unavailable",
13510
+ message: "The project locations file could not be read or written. Check its ownership and permissions."
13511
+ };
13512
+ return {
13513
+ statusCode: 400,
13514
+ code: "invalid_location",
13515
+ message: error.message
13516
+ };
13517
+ }
12733
13518
  if (error instanceof TeamCredentialError) return {
12734
13519
  statusCode: 400,
12735
13520
  code: error.blocker.code,
@@ -12814,7 +13599,7 @@ function nativeSocketValidationOptions(input) {
12814
13599
  ...input.cliAllowedOrigins ? { allowedOrigins: input.cliAllowedOrigins } : {}
12815
13600
  };
12816
13601
  }
12817
- async function runAgentServer(argv) {
13602
+ async function runAgentServer(argv, ports = {}) {
12818
13603
  if (isHelpFlag(argv)) {
12819
13604
  console.log(AGENT_SERVER_HELP);
12820
13605
  return 0;
@@ -12866,6 +13651,7 @@ async function runAgentServer(argv) {
12866
13651
  name: "agent-daemon.server",
12867
13652
  level: envConfig.logLevel || "info"
12868
13653
  });
13654
+ const shutdownDisposers = [];
12869
13655
  try {
12870
13656
  try {
12871
13657
  return await withAgentServerLock(settingsRoot, async () => {
@@ -12891,9 +13677,12 @@ async function runAgentServer(argv) {
12891
13677
  logger
12892
13678
  });
12893
13679
  const runtimeRegistry = new RuntimeRegistry(store.root);
13680
+ const supplied = ports.configure?.(store);
12894
13681
  const runs = new RunManager({
13682
+ ...supplied?.runOptions,
12895
13683
  store,
12896
13684
  storeRoot: settingsRoot,
13685
+ projectRoot: settingsRoot,
12897
13686
  secretProviders,
12898
13687
  externalSecretProviders,
12899
13688
  baseEnv: processEnvSnapshot(),
@@ -12903,16 +13692,17 @@ async function runAgentServer(argv) {
12903
13692
  });
12904
13693
  if (nativeSocket) await validateNativeSocket(nativeSocket);
12905
13694
  const selfOrigin = nativeSocket ? void 0 : `http://127.0.0.1:${port}`;
13695
+ const operatorOAuth = new OperatorOAuth({
13696
+ issuer: connection.issuer,
13697
+ authorizationUrl: new URL("/oauth2/auth", connection.publicUrl).href,
13698
+ tokenUrl: new URL("/oauth2/token", connection.publicUrl).href,
13699
+ jwksUrl: new URL("/.well-known/jwks.json", connection.publicUrl).href,
13700
+ nativeClientId: connection.nativeClientId,
13701
+ callbackPort: OPERATOR_OAUTH.callbackPort
13702
+ }, root);
12906
13703
  const app = buildAgentServer({
12907
- operatorOAuth: new OperatorOAuth({
12908
- issuer: connection.issuer,
12909
- authorizationUrl: new URL("/oauth2/auth", connection.publicUrl).href,
12910
- tokenUrl: new URL("/oauth2/token", connection.publicUrl).href,
12911
- jwksUrl: new URL("/.well-known/jwks.json", connection.publicUrl).href,
12912
- nativeClientId: connection.nativeClientId,
12913
- consoleClientId: connection.consoleClientId,
12914
- callbackPort: OPERATOR_OAUTH.callbackPort
12915
- }, root),
13704
+ ...supplied?.catalogueAgentFor ? { catalogueAgentFor: supplied.catalogueAgentFor } : {},
13705
+ operatorOAuth,
12916
13706
  nativeOnly: Boolean(nativeSocket),
12917
13707
  connectionSettings,
12918
13708
  operatorApiUrl: connection.apiUrl,
@@ -12952,7 +13742,7 @@ async function runAgentServer(argv) {
12952
13742
  else console.error(`allowed origins: ${allowedOrigins.join(", ")}`);
12953
13743
  if (nativeClient) console.error("native desktop client: authorized");
12954
13744
  if (!nativeSocket) console.error("Connect from an allowed local-control client after operator authorization.");
12955
- return await waitForAgentServerShutdown(runs, app, shutdownController, Boolean(values.supervised));
13745
+ return await waitForAgentServerShutdown(runs, app, shutdownController, Boolean(values.supervised), (dispose) => shutdownDisposers.push(dispose));
12956
13746
  } catch (cause) {
12957
13747
  await app.close().catch(() => void 0);
12958
13748
  throw cause;
@@ -12975,10 +13765,14 @@ async function runAgentServer(argv) {
12975
13765
  throw cause;
12976
13766
  }
12977
13767
  } finally {
12978
- await shutdownLogger();
13768
+ try {
13769
+ await shutdownLogger();
13770
+ } finally {
13771
+ for (const dispose of shutdownDisposers) dispose();
13772
+ }
12979
13773
  }
12980
13774
  }
12981
- function waitForAgentServerShutdown(runs, app, shutdownController, supervised) {
13775
+ function waitForAgentServerShutdown(runs, app, shutdownController, supervised, registerDisposer) {
12982
13776
  return new Promise((resolvePromise) => {
12983
13777
  let shuttingDown = false;
12984
13778
  const shutdown = (source) => {
@@ -13009,8 +13803,6 @@ function waitForAgentServerShutdown(runs, app, shutdownController, supervised) {
13009
13803
  if (forcedExitTimer) clearTimeout(forcedExitTimer);
13010
13804
  const failures = results.filter((result) => result.status === "rejected");
13011
13805
  for (const failure of failures) console.error(`shutdown cleanup failed: ${failure.reason.message}`);
13012
- handlers.dispose();
13013
- stdinGuard.dispose();
13014
13806
  const exitCode = typeof process.exitCode === "number" ? process.exitCode : 0;
13015
13807
  resolvePromise(failures.length > 0 ? 1 : exitCode);
13016
13808
  })();
@@ -13023,6 +13815,10 @@ function waitForAgentServerShutdown(runs, app, shutdownController, supervised) {
13023
13815
  enabled: supervised,
13024
13816
  shutdown: () => shutdown("stdin")
13025
13817
  });
13818
+ registerDisposer(() => {
13819
+ handlers.dispose();
13820
+ stdinGuard.dispose();
13821
+ });
13026
13822
  });
13027
13823
  }
13028
13824
  /** Makes the supervising process's stdin pipe part of the server lifecycle. */
@@ -13391,13 +14187,24 @@ async function writeCache(cache) {
13391
14187
  }
13392
14188
  //#endregion
13393
14189
  //#region src/version.ts
13394
- var DAEMON_VERSION = "0.63.0";
14190
+ var DAEMON_VERSION = "0.64.0";
13395
14191
  //#endregion
13396
14192
  //#region src/cli.ts
13397
14193
  async function runAgentDaemonCli(options) {
13398
- const notice = legacyStoreNotice();
13399
- if (notice) console.error(notice);
13400
14194
  const [subcommand, ...rest] = options.argv ?? process.argv.slice(2);
14195
+ const help = subcommand === "--help" || subcommand === "-h" || [
14196
+ "poll",
14197
+ "drain",
14198
+ "once",
14199
+ "server",
14200
+ "providers",
14201
+ "sync-sessions"
14202
+ ].includes(subcommand ?? "") && isHelpFlag(rest);
14203
+ const notice = legacyStoreNotice();
14204
+ if (notice && !help) {
14205
+ console.error(notice);
14206
+ markLegacyStoreNoticeShown();
14207
+ }
13401
14208
  if (["poll", "drain"].includes(subcommand ?? "") && !isWorkspaceInvocation()) checkDaemonUpdate({ currentVersion: DAEMON_VERSION }).then((update) => {
13402
14209
  if (update.updateAvailable) pino({ name: "moltnet.agent-daemon" }).info({
13403
14210
  event: "agent-daemon.update_available",