@ours.network/fleet 0.13.0 → 0.13.2

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.
@@ -207,6 +207,11 @@ export function makeClaudeCodeAdapter(exec = realExec) {
207
207
  : bundledAcpAgent('@agentclientprotocol/claude-agent-acp', 'claude-agent-acp', 'claude-agent-acp');
208
208
  return { argv, env: prep.env };
209
209
  },
210
+ // Same source as buildLaunch's --permission-mode flag, so the tmux and ACP
211
+ // backends cannot disagree about what a role's permissions translate to.
212
+ acpPermissionModeId(role) {
213
+ return permissionMode(role);
214
+ },
210
215
  isolationPaths(_role, _dirs) {
211
216
  const claudeHome = join(home(), '.claude');
212
217
  return {
@@ -98,6 +98,12 @@ export interface HarnessAdapter {
98
98
  prepareSession(role: ResolvedRole, dirs: RoleDirs): Promise<SessionPrep>;
99
99
  buildLaunch(role: ResolvedRole, mode: 'fresh' | 'resume', s: SessionState, prep: SessionPrep): Launch;
100
100
  buildAcpLaunch?(role: ResolvedRole, prep: SessionPrep): AcpLaunch;
101
+ /**
102
+ * The native permission-mode id an ACP session should run at, from the same
103
+ * translation `buildLaunch` uses for its CLI flag. `undefined` keeps the
104
+ * agent's default. Omit for a harness whose ACP agent has no modes.
105
+ */
106
+ acpPermissionModeId?(role: ResolvedRole): string | undefined;
101
107
  /**
102
108
  * REQUIRED. Every adapter must either translate neutral permissions or
103
109
  * explicitly declare that it cannot. Enforced at registration.
@@ -88,6 +88,16 @@ function parseTranscription(value, wireId) {
88
88
  fileWireId: wireId,
89
89
  } };
90
90
  }
91
+ // Voice notes ride "<base>; x-ours-kind=voice-message" verbatim end to end
92
+ // (the recorder's real container varies: audio/webm Chrome/Android, audio/mp4
93
+ // iOS Safari, audio/ogg fallback), so policy for voice messages applies to the
94
+ // base container type. Ordinary files keep exact allowlist matching.
95
+ function baseMime(mime) {
96
+ return mime.split(';')[0].trim();
97
+ }
98
+ function policyMime(file) {
99
+ return file.kind === 'voice_message' ? baseMime(file.mime) : file.mime;
100
+ }
91
101
  export function validateAttachmentSelection(files, config) {
92
102
  if (!config.enabled)
93
103
  return 'attachments are disabled for this owner channel';
@@ -95,7 +105,10 @@ export function validateAttachmentSelection(files, config) {
95
105
  return `the request exceeds the ${config.max_files_per_request}-file limit`;
96
106
  let total = 0;
97
107
  for (const file of files) {
98
- if (!config.allowed_mime.includes(file.mime))
108
+ const mime = policyMime(file);
109
+ if (file.kind === 'voice_message' && !mime.startsWith('audio/'))
110
+ return `voice-message MIME type ${file.mime || '(missing)'} is not an audio container`;
111
+ if (!config.allowed_mime.includes(mime))
99
112
  return `MIME type ${file.mime || '(missing)'} is not allowed`;
100
113
  if (file.size > config.max_file_bytes)
101
114
  return `a file exceeds the ${config.max_file_bytes}-byte limit`;
@@ -148,9 +161,10 @@ export async function admitAttachments(files, dir, config) {
148
161
  total += bytes.length;
149
162
  if (total > config.max_request_bytes)
150
163
  throw new Error('retrieved attachments exceed the request size limit');
151
- const detectedMime = detectMime(bytes, file.mime);
152
- if (!mimeCompatible(file.mime, detectedMime))
153
- throw new Error(`retrieved attachment content does not match declared MIME ${file.mime}`);
164
+ const declaredMime = policyMime(file);
165
+ const detectedMime = detectMime(bytes, declaredMime);
166
+ if (!mimeCompatible(declaredMime, detectedMime))
167
+ throw new Error(`retrieved attachment content does not match declared MIME ${declaredMime}`);
154
168
  const filename = sanitizeFilename(file.filename);
155
169
  const finalPath = join(dir, `${index + 1}-${file.wireId.slice(0, 12)}-${filename}`);
156
170
  const tmp = join(dir, `.${basename(finalPath)}.${randomUUID()}.tmp`);
@@ -175,7 +189,7 @@ export async function admitAttachments(files, dir, config) {
175
189
  await rm(tmp, { force: true });
176
190
  await chmod(finalPath, 0o600);
177
191
  admitted.push({
178
- wireId: file.wireId, filename, path: finalPath, declaredMime: file.mime,
192
+ wireId: file.wireId, filename, path: finalPath, declaredMime,
179
193
  detectedMime, size: bytes.length, sha256: digest, kind: file.kind,
180
194
  ...(file.transcription ? { transcription: {
181
195
  configured: file.transcription.configured, attempted: file.transcription.attempted,
package/dist/runner.js CHANGED
@@ -442,6 +442,7 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
442
442
  stateDir: dir,
443
443
  mode,
444
444
  permissions: perms,
445
+ modeId: adapter.acpPermissionModeId?.(role),
445
446
  log: deps.log,
446
447
  });
447
448
  pid = acpSession.pid;
@@ -8,6 +8,8 @@ export interface AcpSessionOptions {
8
8
  stateDir: string;
9
9
  mode: 'fresh' | 'resume';
10
10
  permissions: CommonPermissions;
11
+ /** Native permission-mode id to request via session/set_mode; undefined keeps the agent default. */
12
+ modeId?: string;
11
13
  log(line: string): void;
12
14
  }
13
15
  /**
@@ -251,6 +251,22 @@ export class AcpSession {
251
251
  this.sessionId = created.sessionId;
252
252
  }
253
253
  writeFileSync(this.sessionFile, this.sessionId + '\n', { mode: 0o600 });
254
+ // Deliver the configured permission mode whichever way the session came up
255
+ // (new, resume or load) — the launch flag never reaches an ACP agent. A
256
+ // refusal is loud but never fatal: the session then simply runs at the
257
+ // agent's own default.
258
+ if (this.options.modeId) {
259
+ try {
260
+ await this.connection.agent.request(acp.methods.agent.session.setMode, {
261
+ sessionId: this.sessionId,
262
+ modeId: this.options.modeId,
263
+ });
264
+ }
265
+ catch (e) {
266
+ this.options.log(`[${this.options.name}] acp: session/set_mode "${this.options.modeId}" failed ` +
267
+ `(${e instanceof Error ? e.message : String(e)}) — session runs at the agent default permission mode`);
268
+ }
269
+ }
254
270
  this.readiness = 'idle';
255
271
  this.events.emit('state', { status: 'idle', text: `ACP session ${this.sessionId}` });
256
272
  }
package/dist/spawn.js CHANGED
@@ -253,6 +253,7 @@ function provenanceSettings(o, defaults) {
253
253
  ? { value: explicitModel, source: 'cli' }
254
254
  : { value: inheritedModel, source: inheritedModel ? 'fleet-default' : 'built-in' },
255
255
  coordinator: provenanceOf(o.coordinator, undefined, undefined),
256
+ permission_mode: provenanceOf(o.permissionMode, undefined, undefined),
256
257
  approval: provenanceOf(o.approval, perms.approval, 'ask'),
257
258
  filesystem: provenanceOf(o.filesystem, perms.filesystem, 'workspace'),
258
259
  unattended: provenanceOf(o.unattended, perms.unattended, 'deny'),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ours.network/fleet",
3
- "version": "0.13.0",
3
+ "version": "0.13.2",
4
4
  "description": "Harness-agnostic fleet of persistent, identity-bound AI agents. Declarative fleet.yaml, tmux or ACP sessions, supervision, and ours.network messaging.",
5
5
  "type": "module",
6
6
  "license": "FSL-1.1-Apache-2.0",