agents-city 0.4.0 → 0.5.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.
Files changed (98) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/README.es.md +240 -11
  3. package/README.md +238 -11
  4. package/benchmarks/latency/fake-claude-cli.mjs +2 -1
  5. package/benchmarks/reception/README.md +16 -0
  6. package/benchmarks/reception/run.mjs +84 -0
  7. package/bin/agents-city.js +1 -0
  8. package/bin/connect +5 -0
  9. package/bin/hall.html +117 -18
  10. package/bin/navegador.mjs +170 -1
  11. package/bin/serve.py +201 -101
  12. package/bin/test +15 -3
  13. package/bin/test-arnes.py +192 -0
  14. package/bin/test-cage.py +9 -0
  15. package/bin/test-channel.py +178 -12
  16. package/bin/test-claude-runtime.py +132 -4
  17. package/bin/test-connect-client.mjs +609 -0
  18. package/bin/test-connect.py +52 -0
  19. package/bin/test-contracts.py +214 -0
  20. package/bin/test-diario.py +185 -0
  21. package/bin/test-doctor.py +31 -0
  22. package/bin/test-i18n.py +189 -0
  23. package/bin/test-navegador.py +61 -0
  24. package/bin/test-seat.py +78 -20
  25. package/bin/test-security.py +2 -0
  26. package/bin/test-serve.py +366 -0
  27. package/city/web/dist-hall/hall.js +789 -214
  28. package/city/web/src/bienvenida.ts +14 -13
  29. package/city/web/src/casa.ts +143 -56
  30. package/city/web/src/demo.ts +61 -31
  31. package/city/web/src/es.ts +187 -27
  32. package/city/web/src/explorador.ts +70 -43
  33. package/city/web/src/hall.ts +520 -103
  34. package/city/web/src/idioma.ts +21 -1
  35. package/city/web/src/motores.ts +48 -1
  36. package/city/web/src/vista.ts +58 -0
  37. package/demo/graba.py +5 -10
  38. package/docs/managed-connect.md +237 -0
  39. package/docs/security.md +41 -2
  40. package/package.json +8 -4
  41. package/plugin/.claude-plugin/plugin.json +1 -1
  42. package/plugin/channel/adapter-prompts.ts +10 -0
  43. package/plugin/channel/adapter.js +78 -31
  44. package/plugin/channel/agents_city_hybrid_crypto_bg.wasm +0 -0
  45. package/plugin/channel/bus.js +109 -65
  46. package/plugin/channel/bus.ts +11 -1
  47. package/plugin/channel/client.js +67 -30
  48. package/plugin/channel/delivery-queue.ts +160 -21
  49. package/plugin/channel/hub/remote-roads.ts +32 -1
  50. package/plugin/channel/hub/road-controller.ts +116 -16
  51. package/plugin/channel/kinsh_vodozemac_wasm_bg.wasm +0 -0
  52. package/plugin/channel/licenses/CONNECT_CLIENT_THIRD_PARTY_NOTICES.md +21 -0
  53. package/plugin/channel/licenses/HYBRID_CRYPTO_THIRD_PARTY_NOTICES.md +12 -0
  54. package/plugin/channel/licenses/hybrid-crypto-Apache-2.0.txt +201 -0
  55. package/plugin/channel/licenses/keyring-MIT.txt +21 -0
  56. package/plugin/channel/licenses/vodozemac-Apache-2.0.txt +201 -0
  57. package/plugin/channel/local-hub.js +2080 -97
  58. package/plugin/channel/local-hub.ts +5 -4
  59. package/plugin/channel/managed-connect/bridge.ts +208 -0
  60. package/plugin/channel/managed-connect/cli.ts +416 -0
  61. package/plugin/channel/managed-connect/device.ts +15 -0
  62. package/plugin/channel/managed-connect/local-cities.ts +94 -0
  63. package/plugin/channel/managed-connect/person-message.ts +74 -0
  64. package/plugin/channel/managed-connect/reception-bridge.ts +341 -0
  65. package/plugin/channel/managed-connect/relay-session.ts +7 -0
  66. package/plugin/channel/managed-connect/storage.ts +653 -0
  67. package/plugin/channel/managed-connect/transport.ts +87 -0
  68. package/plugin/channel/managed-connect-cli.js +4777 -0
  69. package/plugin/channel/managed-connect-cli.ts +7 -0
  70. package/plugin/channel/managed-connect-client.d.ts +261 -0
  71. package/plugin/channel/managed-connect-client.js +6572 -0
  72. package/plugin/channel/managed-connect-client.manifest.json +38 -0
  73. package/plugin/channel/package-lock.json +123 -105
  74. package/plugin/channel/package.json +4 -4
  75. package/plugin/channel/protocol.ts +4 -0
  76. package/plugin/channel/reception.ts +896 -0
  77. package/plugin/channel/road-cli.ts +1 -1
  78. package/plugin/channel/runtime/arnes.json +189 -0
  79. package/plugin/channel/runtime/arnes.ts +47 -0
  80. package/plugin/channel/runtime/claude.ts +18 -0
  81. package/plugin/channel/runtime/codex-config.ts +51 -1
  82. package/plugin/channel/runtime/codex.ts +31 -11
  83. package/plugin/channel/runtime/kimi.ts +6 -4
  84. package/plugin/channel/runtime-files.ts +39 -5
  85. package/plugin/channel/runtime-gateway.js +426 -100
  86. package/plugin/channel/runtime-gateway.ts +12 -0
  87. package/plugin/channel/trust/agents-city-sandbox-roots.json +66 -0
  88. package/plugin/scripts/arnes.py +271 -0
  89. package/plugin/scripts/busca.py +46 -10
  90. package/plugin/scripts/cage.py +5 -0
  91. package/plugin/scripts/city-session.sh +144 -28
  92. package/plugin/scripts/crecimiento.py +4 -1
  93. package/plugin/scripts/demos.py +58 -43
  94. package/plugin/scripts/desinstala.py +36 -21
  95. package/plugin/scripts/diario.py +119 -0
  96. package/plugin/scripts/doctor.py +90 -15
  97. package/plugin/scripts/read-card.py +48 -8
  98. package/plugin/scripts/reception.py +665 -0
@@ -5,7 +5,7 @@ export async function roadBusCli(args: string[]): Promise<unknown> {
5
5
  if (['help', '-h', '--help'].includes(verb)) {
6
6
  return {
7
7
  usage: 'agents-city bus <roster | inbox | send owner/city text>',
8
- note: 'Only the seat can cross a road; repo agents are rejected by the bus ACL.',
8
+ note: 'Only the seat can cross a road. Inbox returns a bounded batch so a burst never becomes one model turn per message.',
9
9
  };
10
10
  }
11
11
  if (verb === 'roster') return busCommand('road.roster');
@@ -0,0 +1,189 @@
1
+ {
2
+ "protocolo": "agents-city-arnes/1",
3
+ "_": [
4
+ "What this product does to somebody else's CLI, declared once.",
5
+ "",
6
+ "The pitch is that we orchestrate the CLIs people already have, with the",
7
+ "plugins, skills, MCP servers and settings they already configured. That is",
8
+ "only true if it can be audited, and it can only be audited if the runtime",
9
+ "and the report read the SAME file. So they do: `codex.ts` takes its policy",
10
+ "values from `trato` below, and `agents-city doctor --config` prints this",
11
+ "same table filled in with what is actually on the machine. Change a value",
12
+ "here and both move together; there is nowhere for a claim to drift from a",
13
+ "behaviour.",
14
+ "",
15
+ " trato what we add or override, and why. This is the deal: without it",
16
+ " the bus is not the only route and the cage does not hold. It is",
17
+ " deliberately short, and every line of it is printable.",
18
+ " hereda what we deliberately do NOT send, so their own CLI reads their",
19
+ " own configuration for it.",
20
+ " respeta what loads untouched."
21
+ ],
22
+ "motores": {
23
+ "claude": {
24
+ "binario": "claude",
25
+ "config": [
26
+ "~/.claude/settings.json",
27
+ "~/.claude/settings.local.json"
28
+ ],
29
+ "trato": [
30
+ {
31
+ "clave": "crossSessionInbound",
32
+ "valor": "refuse",
33
+ "via": "--settings (an added layer, not a replacement)",
34
+ "porque": "the city bus is the only route between agents; Claude's own cross-session path would be a second one nobody can see",
35
+ "rinde": "settings"
36
+ },
37
+ {
38
+ "clave": "disallowedTools",
39
+ "valor": "SendMessage,ListAgents",
40
+ "via": "--disallowed-tools",
41
+ "porque": "same reason, from the other side: an agent must not reach a peer directly",
42
+ "rinde": "flag"
43
+ },
44
+ {
45
+ "clave": "skipDangerousModePermissionPrompt",
46
+ "valor": "true",
47
+ "via": "--settings",
48
+ "porque": "suppresses Claude's local yolo notice only; it does not grant anything",
49
+ "rinde": "settings"
50
+ }
51
+ ],
52
+ "hereda": [
53
+ {
54
+ "suyo": "model",
55
+ "cuando": "no `model.<window>` on the card"
56
+ },
57
+ {
58
+ "suyo": "effortLevel",
59
+ "cuando": "no `effort.<window>` on the card"
60
+ },
61
+ {
62
+ "suyo": "permissions",
63
+ "cuando": "always, unless this city runs yolo"
64
+ }
65
+ ],
66
+ "respeta": [
67
+ "plugins",
68
+ "skills",
69
+ "MCP servers",
70
+ "statusline",
71
+ "theme",
72
+ "hooks",
73
+ "output styles"
74
+ ]
75
+ },
76
+ "codex": {
77
+ "binario": "codex",
78
+ "config": [
79
+ "~/.codex/config.toml"
80
+ ],
81
+ "trato": [
82
+ {
83
+ "clave": "developerInstructions",
84
+ "valor": "You are one member of an Agents City committee. Never contact repo peers directly; submit evidence through the agents-city committee CLI named in each assignment.",
85
+ "via": "thread configuration",
86
+ "porque": "the committee is chaired: a member that answers its peers directly is not being moderated by anybody"
87
+ },
88
+ {
89
+ "clave": "sandbox",
90
+ "valor": "workspace-write",
91
+ "via": "thread configuration and every turn",
92
+ "porque": "the cage: writes stay inside the workspace. `alterno` instead when an outer cage is already holding, or when CITY_CAGE=0 turns it off deliberately",
93
+ "alterno": "danger-full-access"
94
+ },
95
+ {
96
+ "clave": "approvalPolicy",
97
+ "valor": "on-request",
98
+ "via": "thread configuration and every turn",
99
+ "porque": "the default when their config says nothing. `never` rejects anything needing approval, which silently disables app and MCP tools",
100
+ "suyo": "approval_policy"
101
+ }
102
+ ],
103
+ "hereda": [
104
+ {
105
+ "suyo": "model",
106
+ "cuando": "no `model.<window>` on the card"
107
+ },
108
+ {
109
+ "suyo": "model_reasoning_effort",
110
+ "cuando": "no `effort.<window>` on the card"
111
+ },
112
+ {
113
+ "suyo": "approval_policy",
114
+ "cuando": "they set one"
115
+ },
116
+ {
117
+ "suyo": "personality",
118
+ "cuando": "always"
119
+ },
120
+ {
121
+ "suyo": "features",
122
+ "cuando": "always"
123
+ }
124
+ ],
125
+ "respeta": [
126
+ "MCP servers",
127
+ "profiles",
128
+ "personality",
129
+ "features",
130
+ "notify"
131
+ ],
132
+ "avisa": [
133
+ {
134
+ "cuando": "approval_policy = never",
135
+ "dice": "your Codex will refuse anything needing approval, app and MCP tools included"
136
+ }
137
+ ]
138
+ },
139
+ "opencode": {
140
+ "binario": "opencode",
141
+ "config": [
142
+ "~/.config/opencode/opencode.json"
143
+ ],
144
+ "trato": [],
145
+ "hereda": [
146
+ {
147
+ "suyo": "model",
148
+ "cuando": "no `model.<window>` on the card"
149
+ }
150
+ ],
151
+ "respeta": [
152
+ "agents",
153
+ "providers",
154
+ "MCP servers",
155
+ "keybinds"
156
+ ]
157
+ },
158
+ "kimi": {
159
+ "binario": "kimi",
160
+ "config": [
161
+ "~/.kimi/config.json"
162
+ ],
163
+ "trato": [
164
+ {
165
+ "clave": "system_prompt",
166
+ "valor": "You are one member of an Agents City committee. Never contact repo peers directly; submit evidence through the agents-city committee CLI named in each assignment.",
167
+ "via": "agent_config, when the session is created",
168
+ "porque": "the committee is chaired: a member that answers its peers directly is not being moderated by anybody"
169
+ },
170
+ {
171
+ "clave": "permission_mode",
172
+ "valor": "manual",
173
+ "via": "agent_config, on the session and on every turn",
174
+ "porque": "what a turn gets when nothing asks for more: it stops for approval. `auto` when the command says --auto, `yolo` when this city runs yolo or the command says --yolo"
175
+ }
176
+ ],
177
+ "hereda": [
178
+ {
179
+ "suyo": "model",
180
+ "cuando": "no `model.<window>` on the card"
181
+ }
182
+ ],
183
+ "respeta": [
184
+ "providers",
185
+ "MCP servers"
186
+ ]
187
+ }
188
+ }
189
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * The harness declaration, in the runtimes' own language.
3
+ *
4
+ * `arnes.json` is the single source of truth for what this product adds to
5
+ * somebody else's CLI. The connectors read their policy values from here rather
6
+ * than spelling them inline, so `agents-city doctor --config` can print the
7
+ * same table and be believed: there is one place to change, and both move.
8
+ */
9
+ import declaracion from './arnes.json' with { type: 'json' };
10
+
11
+ export interface Trato {
12
+ clave: string;
13
+ valor: string;
14
+ via: string;
15
+ porque: string;
16
+ /** Their own config key, when this one is a default they can outrank. */
17
+ suyo?: string;
18
+ /** The other value this key takes, when the choice is ours to compute. */
19
+ alterno?: string;
20
+ }
21
+
22
+ interface Motor {
23
+ binario: string;
24
+ config: string[];
25
+ trato: Trato[];
26
+ hereda: Array<{ suyo: string; cuando: string }>;
27
+ respeta: string[];
28
+ avisa?: Array<{ cuando: string; dice: string }>;
29
+ }
30
+
31
+ const MOTORES = (declaracion as { motores: Record<string, Motor> }).motores;
32
+
33
+ /** The same value in the app-server's camelCase spelling: `workspace-write`
34
+ * and `workspaceWrite` are one decision written twice, and only one of them
35
+ * gets to be the source. */
36
+ export function camello(valor: string): string {
37
+ return valor.replace(/-([a-z])/g, (_, letra: string) => letra.toUpperCase());
38
+ }
39
+
40
+ /** One declared value, by runtime and key. Throws rather than guess: a missing
41
+ * entry means the declaration and the code have parted ways, which is the one
42
+ * thing this file exists to prevent. */
43
+ export function trato(nombre: string, clave: string): Trato {
44
+ const encontrado = MOTORES[nombre]?.trato.find((t) => t.clave === clave);
45
+ if (!encontrado) throw new Error(`arnes.json declares no ${clave} for ${nombre}`);
46
+ return encontrado;
47
+ }
@@ -17,6 +17,7 @@ interface ClaudeTurn {
17
17
  timer: NodeJS.Timeout;
18
18
  resolveAcceptance: (at: string) => void;
19
19
  rejectAcceptance: (error: Error) => void;
20
+ releaseTurn: () => void;
20
21
  }
21
22
 
22
23
  /**
@@ -33,6 +34,7 @@ export class ClaudeConnector implements RuntimeConnector {
33
34
  private stderrTail = '';
34
35
  private sessionId = '';
35
36
  private turns: ClaudeTurn[] = [];
37
+ private turnDone: Promise<void> = Promise.resolve();
36
38
  private closing = false;
37
39
  private ready = false;
38
40
  private fatalError: Error | null = null;
@@ -123,8 +125,20 @@ export class ClaudeConnector implements RuntimeConnector {
123
125
  }
124
126
 
125
127
  async accept(prompt: string, envelope: BusEnvelope): Promise<NativeAcceptance> {
128
+ // Claude's stream-json transport acknowledges input before the model has
129
+ // finished its turn. Serialize here, as the Codex/OpenCode/Kimi connectors
130
+ // already do, so a busy seat applies durable local backpressure instead of
131
+ // starting an unbounded number of concurrent model turns.
132
+ const previousTurn = this.turnDone;
133
+ let releaseTurn: () => void = () => {};
134
+ const currentTurn = new Promise<void>((resolve) => {
135
+ releaseTurn = resolve;
136
+ });
137
+ this.turnDone = previousTurn.then(() => currentTurn);
138
+ await previousTurn;
126
139
  const child = this.child;
127
140
  if (!this.ready || !child?.stdin?.writable) {
141
+ releaseTurn();
128
142
  throw this.fatalError || new Error('Claude stream connector is not ready');
129
143
  }
130
144
  const uuid = randomUUID();
@@ -151,6 +165,7 @@ export class ClaudeConnector implements RuntimeConnector {
151
165
  }, acknowledgementTimeoutMs()),
152
166
  resolveAcceptance,
153
167
  rejectAcceptance,
168
+ releaseTurn,
154
169
  };
155
170
  this.turns.push(turn);
156
171
  const input = {
@@ -184,6 +199,7 @@ export class ClaudeConnector implements RuntimeConnector {
184
199
 
185
200
  async close(): Promise<void> {
186
201
  this.closing = true;
202
+ this.ready = false;
187
203
  const child = this.child;
188
204
  this.child = null;
189
205
  for (const turn of this.turns) {
@@ -326,6 +342,7 @@ export class ClaudeConnector implements RuntimeConnector {
326
342
  turn.completed = true;
327
343
  clearTimeout(turn.timer);
328
344
  this.turns = this.turns.filter((candidate) => candidate !== turn);
345
+ turn.releaseTurn();
329
346
  }
330
347
 
331
348
  private currentTurn(): ClaudeTurn | undefined {
@@ -337,6 +354,7 @@ export class ClaudeConnector implements RuntimeConnector {
337
354
  if (!turn.acknowledged) turn.rejectAcceptance(error);
338
355
  turn.completed = true;
339
356
  this.turns = this.turns.filter((candidate) => candidate !== turn);
357
+ turn.releaseTurn();
340
358
  }
341
359
 
342
360
  private errorOutput(chunk: string): void {
@@ -1,6 +1,56 @@
1
1
  import { spawnSync } from 'child_process';
2
2
  import { delimiter, isAbsolute, resolve } from 'path';
3
- import { existsSync } from 'fs';
3
+ import { existsSync, readFileSync } from 'fs';
4
+ import { homedir } from 'os';
5
+ import { join } from 'path';
6
+
7
+ /**
8
+ * One top-level string out of the owner's `~/.codex/config.toml`.
9
+ *
10
+ * Not a TOML parser, and not pretending to be one: it reads `key = "value"`
11
+ * lines before the first `[table]` and nothing else, which is exactly where
12
+ * `model`, `model_reasoning_effort` and `approval_policy` live. Anything it
13
+ * cannot read confidently comes back empty, and empty means "we did not find
14
+ * a preference", never "they have none" — the difference matters, because the
15
+ * caller uses it to decide whether to impose a default.
16
+ */
17
+ const LEIDO = new Map<string, string>();
18
+
19
+ export function ownerCodexSetting(key: string): string {
20
+ const path = process.env.CODEX_HOME
21
+ ? join(process.env.CODEX_HOME, 'config.toml')
22
+ : join(homedir(), '.codex', 'config.toml');
23
+ // Remembered for the life of the gateway. This is consulted on every accepted
24
+ // turn, and it is a setting the app-server was already started with: reading
25
+ // and splitting the whole file per turn bought nothing it did not already
26
+ // have.
27
+ const recordado = LEIDO.get(`${path}\u0000${key}`);
28
+ if (recordado !== undefined) return recordado;
29
+ const valor = _leeAjuste(path, key);
30
+ LEIDO.set(`${path}\u0000${key}`, valor);
31
+ return valor;
32
+ }
33
+
34
+ function _leeAjuste(path: string, key: string): string {
35
+ let text: string;
36
+ try {
37
+ text = readFileSync(path, 'utf8');
38
+ } catch {
39
+ return '';
40
+ }
41
+ for (const raw of text.split('\n')) {
42
+ const line = raw.trim();
43
+ if (line.startsWith('[')) break; // past the top level
44
+ if (!line || line.startsWith('#')) continue;
45
+ const equals = line.indexOf('=');
46
+ if (equals < 0) continue;
47
+ if (line.slice(0, equals).trim() !== key) continue;
48
+ const value = line.slice(equals + 1).trim();
49
+ const quoted = /^"([^"]*)"$/.exec(value) || /^'([^']*)'$/.exec(value);
50
+ return quoted ? quoted[1] : value;
51
+ }
52
+ return '';
53
+ }
4
54
 
5
55
  interface McpEntry {
6
56
  name?: unknown;
@@ -4,6 +4,8 @@ import { resolve } from 'path';
4
4
  import { BusEnvelope } from '../protocol.js';
5
5
  import { NativeAcceptance } from '../runtime-metrics.js';
6
6
  import { executableFor, hasOption, optionValue } from './command.js';
7
+ import { camello, trato } from './arnes.js';
8
+ import { ownerCodexSetting } from './codex-config.js';
7
9
  import { WebSocketJsonRpc } from './json-rpc.js';
8
10
  import { freeLoopbackPort, spawnNative, terminate, wait } from './process.js';
9
11
  import { ConnectorOptions, NativeUiCommand, RuntimeActivity, RuntimeConnector } from './types.js';
@@ -189,10 +191,7 @@ export class CodexConnector implements RuntimeConnector {
189
191
  clientUserMessageId: envelope.id,
190
192
  ...(model ? { model } : {}),
191
193
  ...(effort ? { effort } : {}),
192
- // Keep approval requests enabled so the gateway can approve them in
193
- // yolo mode. `never` means "reject anything needing approval", which
194
- // silently disabled app/MCP tools even when the owner chose yolo.
195
- approvalPolicy: 'on-request',
194
+ approvalPolicy: this.approvalPolicy(),
196
195
  // Same decoupling as thread/start: yolo keeps its speed (no
197
196
  // approvals, network open) while writes stay inside the workspace.
198
197
  // CITY_CAGE=0 restores the old fully-open behaviour, deliberately.
@@ -380,17 +379,37 @@ export class CodexConnector implements RuntimeConnector {
380
379
  const model = optionValue(this.options.command, ['--model', '-m']);
381
380
  return {
382
381
  ...(model ? { model } : {}),
383
- approvalPolicy: 'on-request',
382
+ approvalPolicy: this.approvalPolicy(),
384
383
  // Approval and confinement are different axes: auto-approve means "do
385
384
  // not ask", never "touch everything". Writes stay inside the workspace
386
385
  // unless the owner explicitly lowers the cage with CITY_CAGE=0.
387
- sandbox: this.providerSandboxIsOuterCaged() ? 'danger-full-access' : 'workspace-write',
388
- developerInstructions:
389
- 'You are one member of an Agents City committee. Never contact repo peers directly; ' +
390
- 'submit evidence through the agents-city committee CLI named in each assignment.',
386
+ sandbox: this.providerSandboxIsOuterCaged()
387
+ ? String(trato('codex', 'sandbox').alterno)
388
+ : trato('codex', 'sandbox').valor,
389
+ developerInstructions: trato('codex', 'developerInstructions').valor,
391
390
  };
392
391
  }
393
392
 
393
+ /**
394
+ * How this Codex asks for permission.
395
+ *
396
+ * Theirs wins. `on-request` is only what we fall back to when their config
397
+ * says nothing — and it is what the declaration says it is, so the doctor's
398
+ * report cannot claim one thing while this claims another.
399
+ *
400
+ * Their `never` is honoured even though it rejects anything needing approval
401
+ * and so disables app and MCP tools: that is their machine and their choice.
402
+ * `doctor --config` says the consequence out loud rather than this quietly
403
+ * deciding they did not mean it.
404
+ */
405
+ private approvalPolicy(): string {
406
+ const declarado = trato('codex', 'approvalPolicy');
407
+ // Their key is named by the declaration too — `suyo` is precisely "the
408
+ // setting of theirs this one defers to", and naming it twice is how the
409
+ // two stop meaning the same thing.
410
+ return ownerCodexSetting(String(declarado.suyo)) || declarado.valor;
411
+ }
412
+
394
413
  private autoApprove(): boolean {
395
414
  return (
396
415
  this.options.autoApprove ||
@@ -403,9 +422,10 @@ export class CodexConnector implements RuntimeConnector {
403
422
  }
404
423
 
405
424
  private sandboxPolicy(): Record<string, unknown> {
406
- if (this.providerSandboxIsOuterCaged()) return { type: 'dangerFullAccess' };
425
+ if (this.providerSandboxIsOuterCaged())
426
+ return { type: camello(String(trato('codex', 'sandbox').alterno)) };
407
427
  return {
408
- type: 'workspaceWrite',
428
+ type: camello(trato('codex', 'sandbox').valor),
409
429
  networkAccess: this.autoApprove(),
410
430
  writableRoots: [],
411
431
  };
@@ -3,6 +3,7 @@ import WebSocket from 'ws';
3
3
  import { BusEnvelope, randomId } from '../protocol.js';
4
4
  import { NativeAcceptance } from '../runtime-metrics.js';
5
5
  import { executableFor, hasOption, optionValue } from './command.js';
6
+ import { trato } from './arnes.js';
6
7
  import { freeLoopbackPort, spawnNative, terminate, wait, waitForHttp } from './process.js';
7
8
  import { ConnectorOptions, RuntimeConnector } from './types.js';
8
9
 
@@ -59,9 +60,7 @@ export class KimiConnector implements RuntimeConnector {
59
60
  agent_config: {
60
61
  ...(model ? { model } : {}),
61
62
  permission_mode: this.permissionMode(),
62
- system_prompt:
63
- 'You are one member of an Agents City committee. Never contact repo peers directly; ' +
64
- 'submit evidence through the agents-city committee CLI named in each assignment.',
63
+ system_prompt: trato('kimi', 'system_prompt').valor,
65
64
  },
66
65
  }),
67
66
  }),
@@ -119,11 +118,14 @@ export class KimiConnector implements RuntimeConnector {
119
118
  this.child = null;
120
119
  }
121
120
 
121
+ /** The fallback is the declaration's, so `doctor --config` cannot promise one
122
+ * mode while this returns another — which it did: the declaration said `auto`
123
+ * and this said `manual`. */
122
124
  private permissionMode(): 'auto' | 'yolo' | 'manual' {
123
125
  if (hasOption(this.options.command, ['--auto'])) return 'auto';
124
126
  if (this.options.autoApprove || hasOption(this.options.command, ['--yolo', '-y']))
125
127
  return 'yolo';
126
- return 'manual';
128
+ return trato('kimi', 'permission_mode').valor as 'auto' | 'yolo' | 'manual';
127
129
  }
128
130
 
129
131
  private auth(): Record<string, string> {
@@ -1,5 +1,17 @@
1
1
  import { randomBytes } from 'crypto';
2
- import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'fs';
2
+ import {
3
+ chmodSync,
4
+ closeSync,
5
+ constants,
6
+ existsSync,
7
+ fsyncSync,
8
+ mkdirSync,
9
+ openSync,
10
+ readFileSync,
11
+ renameSync,
12
+ unlinkSync,
13
+ writeFileSync,
14
+ } from 'fs';
3
15
  import { dirname, join } from 'path';
4
16
  import { CityContext } from './city-config.js';
5
17
  import { ActorCredential, HubEndpoint, safeSegment } from './protocol.js';
@@ -7,13 +19,35 @@ import { ActorCredential, HubEndpoint, safeSegment } from './protocol.js';
7
19
  let counter = 0;
8
20
 
9
21
  export function atomicJson(path: string, value: unknown): void {
10
- mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
22
+ const directory = dirname(path);
23
+ mkdirSync(directory, { recursive: true, mode: 0o700 });
11
24
  const tmp = `${path}.tmp-${process.pid}-${counter++}`;
12
- writeFileSync(tmp, JSON.stringify(value, null, 2) + '\n', { mode: 0o600 });
13
- renameSync(tmp, path);
14
25
  try {
26
+ const fd = openSync(tmp, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL, 0o600);
27
+ try {
28
+ writeFileSync(fd, JSON.stringify(value, null, 2) + '\n');
29
+ fsyncSync(fd);
30
+ } finally {
31
+ closeSync(fd);
32
+ }
33
+ renameSync(tmp, path);
15
34
  chmodSync(path, 0o600);
16
- } catch {}
35
+ try {
36
+ const dirFd = openSync(directory, constants.O_RDONLY);
37
+ try {
38
+ fsyncSync(dirFd);
39
+ } finally {
40
+ closeSync(dirFd);
41
+ }
42
+ } catch {
43
+ // The file itself is synced; a few filesystems do not fsync directories.
44
+ }
45
+ } catch (error) {
46
+ try {
47
+ unlinkSync(tmp);
48
+ } catch {}
49
+ throw error;
50
+ }
17
51
  }
18
52
 
19
53
  export function actorCredential(context: CityContext, actor: string): ActorCredential {