KodaScript Language Reference β
KodaScript is the in-game scripting language. Players write .k source files, compile them into .b executables or .so libraries, and run them on their in-game device.
For the current compiler-facing static typing rules, see KodaScript-Semantics.md.
Implementation boundary:
- pure KodaScript language/compiler work now lives in
kodascript-core/ - game/runtime host APIs such as
Device,Program,OSApi,Connection,ServiceListener, andServiceConnectionremain server-host features
This document describes the full player-facing language plus the current host API surface used by the game.
File Types β
| Extension | Purpose |
|---|---|
.k | Source file β compiled by the player |
.h | Header file β shared declarations, included via #include |
.b | Compiled executable β has a Main() entry point |
.so | Compiled library β loaded by other scripts via Device.Load() or Connection.Load() |
.service | Service configuration file used by the OS service manager |
A .k file with a public static void Main() compiles to .b. Without one, it compiles to .so.
Syntax Basics β
Statements require a semicolon at the end of each line.
var x = 10;
var name = "Alice";
Print(x);
Print($"Hello {name}!");String interpolation requires the $ prefix and uses {expression} inside the string: $"Hello {name}". Without $, braces are treated as literal text.
Interpolation supports:
- operators and expressions:
$"Sum: {a + b}" - conditional expressions:
$"Access: {(isAdmin ? "Granted" : "Denied")}" - alignment:
{value,-10}(left) and{value,10}(right) - format specifier:
{value:C}for currency - literal braces:
{{and}}
Examples:
int a = 5;
int b = 3;
Print($"Sum: {a + b}");
Print($"Square of a: {a * a}");
bool isAdmin = true;
Print($"Access: {(isAdmin ? "Granted" : "Denied")}");
string item = "Laptop";
double cost = 999.99;
Print($"Item: {item,-10} | Cost: {cost,10:C}");
Print($"{{This is a literal brace}}");Comments use // for line comments. Block comments (/* */) and bare # are not supported β both produce compiler errors.
Types β
| Type | Description | Literal example |
|---|---|---|
int | 32-bit signed integer, wraps on overflow | 42 |
long | 64-bit signed integer, wraps on overflow | 100L |
float | 32-bit float (Math.fround precision) | 3.14f |
double | 64-bit float | 3.14 |
bool | Boolean | true / false |
char | Single character | 'a' |
string | Text | "hello" |
var | Inferred type (method bodies only) | var x = 1; |
Type promotion in mixed arithmetic: int β long β float β double.
GetType() is a member call (for example value.GetType()), not a global function. It returns the Koda type name as a string: "int", "long", "float", "double", "bool", "char", "string", "list", "map", "null", or the class name for instances.
There is currently no TypeOf(...) or typeof(...) operator/function in KodaScript. For runtime values and objects, use value.GetType(). This matches C# object inspection more closely than a fake typeof(value) helper would.
Program Global β
Scripts also receive a Program global for metadata about the current script/process instance.
| Method | Returns | Description |
|---|---|---|
GetName() | string | Current script/program name |
GetPath() | string | Absolute script path |
GetPid() | int | Current process id, or 0 if unavailable |
GetWindowId() | string | Attached local window id, or empty string if none |
GetArgs() | string[] | Current script arguments as strings |
Example:
Print(Program.GetName());
Print(Program.GetPath());
Print("" + Program.GetPid());
var windowId = Program.GetWindowId();
if (windowId != "") {
Print("attached to window " + windowId);
}var p = new Player("Alice", 100);
Print(p.GetType()); // "Player"
if (p.GetType() == "Player") {
Print(p.GetName());
}Variables β
int x = 10;
string name = "Bob";
var result = x * 2; // var infers type from the right-hand sidevar is only valid inside method bodies β not as a field type or method return type.
Operators β
+ - * / % // arithmetic
^ // bitwise XOR (integer/bool XOR) β NOT power
== != < > <= >= // comparison (return bool)
&& || ! // logical (operate on bool)
?? // null coalescing: a ?? b returns b only if a is null (not 0 or "")
+= -= *= /= // compound assignmentPower uses Math.Pow(a, b):
Print(2 ^ 3); // 1 (bitwise XOR)
Print(Math.Pow(2, 3)); // 8Control Flow β
if (x > 0) {
Print("positive");
} else if (x == 0) {
Print("zero");
} else {
Print("negative");
}
while (i < 10) {
i += 1;
if (i == 5) { break; }
if (i == 3) { continue; }
}
do {
i += 1;
} while (i < 10);
foreach (var item in [1, 2, 3]) {
Print(item);
}
for (int i = 0; i < 3; i++) {
Print(i);
}
try {
int x = 1 / 0;
} catch (Exception ex) {
Print(ex.Message);
Print(ex.Code);
} finally {
Print("cleanup");
}try/catch/finally currently supports ordered catch clauses in C# style:
try {
// risky code
} catch (TimeoutError ex) when (ex.Message.Contains("timed out")) {
Print("network timeout");
} catch (Exception ex) {
Print(ex.ToString());
} catch (RuntimeError) {
Print("runtime failure");
} finally {
Print("always runs");
}throw; rethrows the current caught exception, and throw ex; throws a caught exception object again. You can also construct and throw built-in exception objects directly:
throw new FileSystemError("File not found", "E_FILE_NOT_FOUND");
throw new NetworkError("Connection refused");The optional second argument is just an error code string. It is not a reserved keyword or built-in enum entry. You can use your own codes such as "E_FILE_NOT_FOUND" or "E_CONN_REFUSED" for script-thrown exceptions.
The first matching catch clause runs. catch (Type) without a variable name is also supported. catch (Type ex) when (condition) is also supported. Filters are checked in order after type matching, and the first truthy filter wins.
Caught exceptions expose:
MessageCodeLineColNameStackGetType()-> runtime subtype name such as"RuntimeError"or"TimeoutError"ToString()
Current base type:
Exception
Current runtime subtype path:
RuntimeError(broad script/runtime catch)TypeErrorPermissionErrorAuthenticationErrorFileSystemErrorNetworkErrorTimeoutErrorIndexError
catch (RuntimeError ex) catches RuntimeError itself and these runtime subtypes. More specific catches such as catch (AuthenticationError ex) or catch (FileSystemError ex) only catch matching subtypes.
Collections β
var list = [1, 2, 3];
list.Push(4);
Print(list.Length); // 4
Print(list[0]); // 1
list.Pop();
list.Remove(0); // remove by index
list.Sort();
list.Reverse();
var joined = list.Join(", ");
var map = {name: "Alice", score: 99};
Print(map.name); // Alice
Print(map["score"]); // 99
map.level = 5;
Print(map.Length); // number of keys
Print(map.name != null); // true
string[] names = ["alice", "bob"];
int[] nums = [1, 2, 3];
Print(names[0]); // "alice"
nums[1] = 42; // typed assignment enforcedBracket access (map["key"], list[0]) is supported on maps and lists. It is not supported on class instances β use dot access for those.
Classes β
Every source file must contain at least one top-level public class.
public class Player {
private int _health;
private string _name;
public Player(string name, int hp) {
this._name = name;
this._health = hp;
}
public int GetHealth() { return this._health; }
public string GetName() { return this._name; }
public static void Main() {
var p = new Player("Alice", 100);
Print(p.GetName());
Print(p.GetHealth());
}
}Main can optionally accept string[] args to receive terminal arguments:
public static void Main(string[] args) {
Print("Hello, " + args[0] + "!");
}publicβ accessible from outside the classprivateβ accessible only within methods of the same class
Fields and methods without an access modifier produce warning W001/W002 and default to private.
Naming convention (recommended, C#-style):
- private fields use leading underscore with camelCase:
_health,_connectionState - method parameters and locals use camelCase:
name,count - public methods/properties/types use PascalCase:
GetHealth(),Device,Player
Auto-Properties β
KodaScript supports C#-style properties:
public class User {
public string Name { get; set; }
public int Level { get; private set; }
public void Promote() {
this.Level = this.Level + 1;
}
}Notes:
- Accessors are declared as
get;andset;for auto-properties - Accessor visibility modifiers are supported (
private set;,private get;) - Custom accessor bodies are also supported as a full pair:
get { ... } set { ... } - Mixed accessor styles are not supported (
get; set { ... }orget { ... } set;) - Static auto-properties are supported with
static
Constructor β
The constructor has the same name as the class and no return type β not even void. Declaring a return type produces E013.
public Player(string name, int hp) {
this._name = name;
this._health = hp;
}Fields are initialised before the constructor runs. If no constructor is defined, a default empty one is implied.
this β
this refers to the current instance inside non-static methods. Using this in a static method throws a runtime error.
Static members β
Static members are declared with the static modifier and accessed via the class name, not an instance. Accessing a static member through an instance throws a runtime error.
public class Counter {
public static int Count;
public static void Main() {
Counter.Count = 0;
Counter.Count += 1;
Print(Counter.Count); // 1
}
}#include β
Header files can be included at the top of a .k file, before any class definition.
#include "utils.h"
public class App {
public static void Main() {
var u = new Utils();
u.DoSomething();
}
}Rules:
- Only
.hfiles can be included (not.so,.b, or.k) #includemust appear before any class definition- Circular includes are detected and rejected
- Duplicate class names across included files produce E018
Built-in Globals β
Device is the hardware/network API global. OS-level functionality is accessed through the OSApi handle returned by Device.GetOS(). Runtime helpers are available where documented (for example Print).
Print(value) β
Prints a value to the terminal.
Print("Hello, World!");
Print(42);Math functions β
Math.Pow(2, 8); // 256
Math.Floor(3.7); // 3
Math.Ceiling(3.2); // 4
Math.Round(3.5); // 4
Math.Abs(-5); // 5
Math.Sqrt(16); // 4
Math.Min(3, 7); // 3
Math.Max(3, 7); // 7
var rng = new Random();
rng.Next(1, 10); // random int in [1, 10] (max exclusive)
rng.NextDouble(); // random double in [0, 1]Range β
foreach (var i in Enumerable.Range(0, 5)) {
Print(i); // 0 1 2 3 4
}Type conversion β
Convert.ToString(42); // "42"
Convert.ToInt32("42"); // 42
Convert.ToBoolean(1); // trueString methods β
string greeting = "hello";
char letter = 'o';
Print(greeting.StartsWith("he")); // true
Print(greeting.EndsWith(letter)); // true (char works too)
string padded = " abc ";
Print(padded.Trim()); // "abc"
string item = "Laptop";
Print(item.Substring(0, 3)); // "Lap"
Print(item.IndexOf("top")); // 3
string alpha = "ABCDEF";
Print(alpha.Slice(1, 4)); // "BCD" (end is exclusive)
string sentence = "The morning is upon us.";
Print(sentence.Slice(3, -2)); // " morning is upon u"
var x = 42;
Print(x.GetType()); // "int"StartsWith(value, ignoreCase, culture) and EndsWith(value, ignoreCase, culture):
ignoreCase = trueenables case-insensitive matching.culturecontrols locale-specific case comparison behavior.
double amount = 1234.56;
var fr = new CultureInfo("fr-FR");
Print(amount.ToString("C", fr)); // currency string in fr-FR style
string word = "Γ
ngstrΓΆm";
bool match = word.StartsWith("A", true, new CultureInfo("sv-SE"));
Print(match); // false in Swedish cultureCase conversion β
ToUpper() and ToLower() are available on string and char.
string name = "hello";
Print(name.ToUpper()); // "HELLO"
Print(name.ToLower()); // "hello"
char c = 'a';
Print(c.ToUpper()); // 'A'
Print(c.ToLower()); // 'a'StringBuilder β
StringBuilder is a mutable string buffer β more efficient than repeated + concatenation when building strings in a loop.
var sb = new StringBuilder();
sb.Append("Hello");
sb.Append(" World!");
Print(sb.ToString()); // "Hello World!"
Print(sb.Length); // 12Constructor overloads:
var sb1 = new StringBuilder(); // empty
var sb2 = new StringBuilder("Hello World!"); // initial string
var sb3 = new StringBuilder(50); // initial capacity hint
var sb4 = new StringBuilder("Hello World!", 50);Methods:
var sb = new StringBuilder("Hello");
sb.Append(" World!");
Print(sb.ToString()); // "Hello World!"
int amount = 25;
sb.AppendFormat("Total: {0:C}", amount);
Print(sb.ToString()); // "Hello World!Total: $25.00"
sb.Insert(6, "Beautiful ");
Print(sb.ToString()); // "Hello Beautiful World!Total: $25.00"
sb.Remove(5, 7);
Print(sb.ToString()); // "Hello World!Total: $25.00"
sb.Replace("World", "KodaScript");
Print(sb.ToString()); // "Hello KodaScript!Total: $25.00"
sb.Clear();
Print(sb.ToString()); // ""Convert to string with ToString():
var sb = new StringBuilder("Hello World!");
string result = sb.ToString();
Print(result); // "Hello World!"Async / Await β
await pauses the current script until an async operation completes and returns its result. It can only be used inside an async method β using it in a non-async method produces E025.
Async API calls are also checked at compile time. If an expression has an internal awaitable type and you assign or use it like the final value without await, compilation fails with E026.
Since most scripts use await, you'll typically declare Main as async. All three forms are valid:
public static async void Main() // can use await
public static async void Main(string[] args) // with args
public static void Main() // no await allowed insideNote on async void:
- In C#,
async voidis usually discouraged except for event handlers. - In KodaScript,
async voidis the normal async method form (includingMain), because there is no publicTasktype in script signatures.
public class App {
public static async void Main() {
OSApi os = Device.GetOS();
var fs = os.GetFileSystem();
var node = await fs.GetNode("/Drive1/home/alice/notes.txt");
Print(node.Name);
}
}Example compile-time error:
Connection conn = Device.Connect("10.0.0.5", 22, "admin", "pass"); // E026
Connection conn2 = await Device.Connect("10.0.0.5", 22, "admin", "pass"); // OKAll Device and Connection methods that contact the server are async and require await: GetComponent(), GetComponents(), Probe(), Connect(), and Load(). OS-level async methods include window management, service management, process management, and user/group operations.
Return Value Conventions β
KodaScript APIs use a consistent return model:
bool(true/false) for pure success/failure operationsnullfor "not found" or "no object"- typed data (
map,list, class handles) for successful queries - domain error strings for expected branchable outcomes on APIs that still use result-code returns
- thrown runtime errors (
Rxxx) for invalid usage/state (for example calling methods on a closed connection)
Internally, the compiler now tracks lightweight metadata for many API calls that may return null or domain error strings, but it does not yet expose full union types in script syntax. Runtime branching remains the correct way to handle those outcomes.
When a known errorable API call is assigned directly into a concrete declared type, the compiler may emit warning W008 to highlight that the value can still be null or an error string at runtime.
When a previously stored result from one of those calls is later used or assigned as if it were already narrowed, the compiler may emit warning W009. Simple branch checks such as if (conn != null) suppress that warning inside the guarded branch for nullable-only APIs. Type guards like if (win is Window) are also recognized where appropriate.
User-defined method returns are stricter: if a method is declared to return a concrete type such as Window, returning an expression that is still statically known to be nullable or errorable is a compile error, not a warning.
Practical examples:
var exists = await fs.Exists("/Drive1/home/alice/notes.txt"); // bool
var node = await fs.GetNode("/Drive1/home/alice/notes.txt"); // node or null
var conn = await Device.Connect("10.0.0.5", 22, "admin", "pass"); // Connection or null; invalid credentials throw AuthenticationError
var closed = conn.Disconnect(); // bool
var os = Device.GetOS();
var runResult = await os.Run("/Drive1/bin/tool.b"); // true, or throws on failure
var win = await os.OpenNode("/Drive1/home/alice/notes.txt"); // Window or null; permission failure throws
var started = await os.StartService(8080, "/Drive1/bin/server.b"); // true, or throws on failureGuideline for scripts:
try {
await os.Run("/Drive1/bin/tool.b");
Print("Success");
} catch (Exception ex) {
Print("Error: " + ex.Message);
}Marking your own method async means it can use await internally. Calling it with await waits for it to finish before continuing β same as any other language.
public class App {
public async string ReadName() {
OSApi os = Device.GetOS();
var fs = os.GetFileSystem();
var node = await fs.GetNode("/Drive1/home/alice/name.txt");
return node.Name;
}
public static async void Main() {
var a = new App();
var name = await a.ReadName();
Print(name);
}
}Device Global β
The Device global provides access to local device hardware, network, and connectivity. It is backed by server-side game state β players cannot construct it.
OS-level operations (filesystem, processes, windows, services, users) are accessed via Device.GetOS(), which returns an OSApi handle.
Device info β
Print(Device.GetName()); // local device name
Print(Device.GetIp()); // local/public IP, or "0.0.0.0" if unavailable
Print(Device.GetType()); // device type (e.g. "computer", "router")
var os = Device.GetOS(); // returns the OSApi handleDevice.GetName(), Device.GetIp(), and Device.GetType() are local metadata lookups and do not require await.
Filesystem β
var fs = Device.GetOS().GetFileSystem();
Print(fs.GetCwd());
fs.SetCwd("/Drive1/home/alice");
var exists = await fs.Exists("/Drive1/home/alice/notes.txt");
var node = await fs.GetNode("/Drive1/home/alice/notes.txt"); // works for files, folders, drives, and apps
await fs.CreateFile("/Drive1/home/alice/new.txt");
await fs.CreateFolder("/Drive1/home/alice/projects");
await fs.CreateApp("/Drive1/home/alice/Explorer", "explorer"); // recreate a system app by appId
await fs.DeleteNode("/Drive1/home/alice/old.txt"); // works for files, folders, and apps
await fs.RenameNode("/Drive1/home/alice/old.txt", "new.txt"); // works for files, folders, apps, and drives
await fs.MoveNode("/Drive1/home/alice/a.txt", "/Drive1/home/alice/docs"); // moves into destination folder; files, folders, and apps only
var bytes = await fs.GetDiskUsage("/Drive1/home/alice"); // recursive total bytes under path
await fs.Chmod("/Drive1/home/alice/script.k", "755");
await fs.Chown("/Drive1/home/alice/script.k", "alice", "users");
await fs.Chgrp("/Drive1/home/alice/script.k", "users");GetNode returns null if the path does not exist. Filesystem mutators follow the same contract as the rest of the modern API surface:
- missing target/path contexts return
falseornull - permission failures throw
PermissionError - invalid names/modes and protected-node cases throw runtime errors with specific codes
For folders and drives, GetNode includes a Children list automatically. Node metadata includes OwnerUser, OwnerGroup, OwnerPerm, GroupPerm, OthersPerm, StickyBit, plus GetPermissions("rwx"). Drives also expose an Unmount() method.
Use Exists to check before fetching, or use null coalescing as a fallback:
var node = await fs.GetNode("/Drive1/home/alice/notes.txt") ?? defaultNode;CreateApp creates an app node with the given appId (e.g. "explorer", "terminal", "texteditor"). This lets players recreate system apps they may have deleted.
Checking node type and iterating children:
var node = await fs.GetNode("/Drive1/home/alice");
if (node != null && node.Type == "folder") {
foreach (var item in node.Children) {
Print(item.Name + " (" + item.Type + ")");
}
}Renaming a drive:
await fs.RenameNode("/Drive1", "MyDrive"); // drive is now accessible at /MyDriveUnmounting a drive (unlinks the storage component β player is sent to BIOS and must remount from the hardware panel):
var drive = await fs.GetNode("/Drive1");
if (drive != null && drive.Type == "drive") {
var result = await drive.Unmount();
// result: true on success, false if already unlinked
}Note: Filesystem CRUD and permission operations (files, folders, apps, ownership, chmod/chown/chgrp) are OS-level concerns accessed via
Device.GetOS().GetFileSystem().Deviceowns storage hardware topology (linked drives, slots, capacity, physical presence).
Hardware components β
var nic = await Device.GetComponent("nic:eth0"); // null if slot not linked
if (nic != null && nic.GetKind() == "nic") {
var data = nic.GetData();
Print(data.NicType); // "wifi" or "ethernet"
Print(await nic.GetMode()); // "managed" / "monitor"
await nic.SetMode("monitor");
var aps = await nic.Scan(); // [] if none in range
}
var storage = await Device.GetComponent("storage:1");
if (storage != null && storage.GetKind() == "storage") {
var d = storage.GetData();
Print(d.Label);
Print(d.SizeGb);
}
var all = await Device.GetComponents();
foreach (var c in all) {
Print(c.GetSlot() + " [" + c.GetKind() + "]");
}NIC/Wi-Fi operations now follow the same modern error contract:
- invalid mode/arguments and invalid NIC state throw runtime errors
- authentication failures when joining secured access points throw
AuthenticationError - unavailable targets like missing APs or absent crack jobs return
null/falsewhere appropriate - discovery-style calls like
Scan()still return empty arrays when nothing is found
Probe β
Probe an IP to check if a device is reachable before connecting. Always returns a ProbeResult β never null.
var probe = await Device.Probe("10.0.0.5");
if (probe.IsReachable()) {
Print(probe.GetName());
Print(probe.GetIp());
var ports = probe.GetOpenPorts();
}Calling GetProcess(), GetProcesses(), GetFileSystem(), GetComponent(), or GetComponents() on a ProbeResult throws β those require an authenticated Connection.
Connect to a remote device β
Device.Connect() provides scripting/API remote access. It does not alter any Terminal context, prompt, CWD, or DesktopUI state β for interactive SSH sessions, use the ssh terminal command instead.
try {
var conn = await Device.Connect("10.0.0.5", 22, "admin", "password");
if (conn == null) {
Print("Host unavailable");
return;
}
var remoteFs = conn.GetFileSystem();
var file = await remoteFs.GetNode("/Drive1/home/admin/secret.txt");
if (file != null) {
Print(file.Name + "." + file.Extension);
}
conn.Disconnect();
} catch (AuthenticationError ex) {
Print("Login failed: " + ex.Message);
}Example: connect and inspect hardware components on target:
public class AuditTarget {
public static async void Main() {
var conn = await Device.Connect("10.0.0.5", 22, "admin", "password");
if (conn == null) {
Print("Host unavailable");
return;
}
var components = await conn.GetComponents();
foreach (var c in components) {
Print(c.GetSlot() + " [" + c.GetKind() + "]");
var d = c.GetData();
if (d.Manufacturer != null && d.Manufacturer != "") { Print(" mfr: " + d.Manufacturer); }
if (d.Model != null && d.Model != "") { Print(" model: " + d.Model); }
if (d.Series != null && d.Series != "") { Print(" series: " + d.Series); }
}
var cpu = await conn.GetComponent("cpu");
var ram1 = await conn.GetComponent("ram:1");
var storage1 = await conn.GetComponent("storage:1");
var nic = await conn.GetComponent("nic:eth0");
if (cpu != null) { Print("CPU: " + cpu.GetData().Model); }
if (ram1 != null) { Print("RAM1: " + ram1.GetData().Model); }
if (storage1 != null) { Print("Storage1: " + storage1.GetData().Label); }
if (nic != null) { Print("NIC type: " + nic.GetData().NicType); }
conn.Disconnect();
}
}After Disconnect(), all methods on the Connection object throw. Disconnect() returns true when it closes an open connection, and false if it was already closed.
The Connection handle exposes: GetName(), GetIp(), GetType(), GetFileSystem(), GetComponent(slot), GetComponents(), GetProcess(pid), GetProcesses(), KillProcess(pid), Run(path, args), Load(path), Disconnect().
Load a compiled library β
Device.Load() loads a compiled .so library and returns an instance of its exported class. Unlike OSApi.Run() which executes a .b executable's Main(), Load() does not run any entry point β it instantiates the class and gives you the object so you can call its methods directly.
var lib = await Device.Load("/Drive1/home/alice/scanner.so");
var result = await lib.ScanSubnet("10.0.0");
Print(result);- Only accepts
.sofiles (not.b,.k, or.h) - Bare names without
/resolve from/<current-drive>/lib/(e.g.Device.Load("scanner.so")β/Drive1/lib/scanner.so) Connection.Load(path)loads from a remote device's filesystem
At compile time, the loaded library value is treated as unknown. The compiler checks the Load(path) call itself, but it does not statically know the exported methods of the loaded .so unless a future library-signature system is added. Library member calls are therefore runtime-driven for now.
Device.Connect() only succeeds when the target is currently reachable and valid for remote execution:
- target device is powered on and enabled
- target has an OS installed
- a service exists on the requested port
- target has an active link to a router with an enabled uplink/public network path
If the target is reachable but the credentials are invalid, Device.Connect() throws AuthenticationError instead of returning null.
When the target service is a scripted daemon such as sshd, Device.Connect() now accepts an authenticated service session as sufficient proof of login. In practice:
connection.StartSession(auth, ...)is enough for a scripted daemon to authenticateDevice.Connect(...)connection.OpenShell(username)is not required forDevice.Connect(...)connection.OpenShell(username)is still the terminal/session handoff primitive used by interactive SSH flows
So the API split is:
Device.Connect(...)-> authenticated remote scripting connectionsshterminal command /OSApi.Ssh(...)-> interactive terminal session handoff
After connection, async calls on the Connection handle revalidate availability. If the target powers off, becomes disabled, loses service/uplink, or otherwise becomes unreachable, calls throw the standard closed error and the handle is considered closed.
Load a library from a remote device β
var conn = await Device.Connect("10.0.0.5", 22, "admin", "pass");
if (conn == null) { return; }
var lib = await conn.Load("/Drive1/lib/netutils.so");
var result = await lib.ScanSubnet("10.0.0");
Print(result);
conn.Disconnect();Processes β
Process management lives on the OSApi object returned by Device.GetOS().
OSApi os = Device.GetOS();
var procs = await os.GetProcesses();
foreach (var p in procs) {
Print(p.Pid + " " + p.Cmd);
}
var p = await os.GetProcess(1234); // null if not found
if (p != null) {
Print(p.Pid + " " + p.Cmd);
}
await os.KillProcess(1234); // terminate a process
await os.DetachProcess(1234); // detach from terminal, keeps running in backgroundOSApi β
Device.GetOS() returns an OSApi handle for OS-level operations: windows, processes, services, filesystem, users, and groups.
OSApi os = Device.GetOS();
await os.GetProcesses();There is no top-level OS global.
If the device has no OS installed, calling methods on Device.GetOS() throws a runtime error.
All OSApi methods that query or mutate server-backed state are async and require await. GetFileSystem() is synchronous and returns the filesystem handle immediately.
Window Management β
OSApi os = Device.GetOS();
var active = await os.GetActiveWindow(); // Window handle or null
var all = await os.GetOpenWindows(); // array of Window handles
var terms = await os.GetOpenWindows("Terminal"); // filtered by type
var term = await os.OpenWindow("Terminal"); // open a new Terminal window
var editor = await os.OpenWindow("CodeEditor"); // open a new CodeEditor window
// Supported appNames: "Terminal", "Explorer", "TextEditor", "CodeEditor"
// Returns null for unrecognized appName
var win = await os.OpenNode("/Drive1/home/alice/notes.txt");
// Opens file in appropriate editor based on extension:
// .k, .h β CodeEditor
// .txt β TextEditor
// Returns: Window handle or null (file not found)
// Throws PermissionError when read access is deniedScripts can interact with any open window β not just the active one. Call methods on any window returned by GetOpenWindows() regardless of focus state.
Note: Window management is a local-device-only operation. Calling
GetActiveWindow()orGetOpenWindows()on aConnectionobject throws a runtime error (R101).
Service Management β
OSApi os = Device.GetOS();
var started = await os.StartService(8080, "/Drive1/home/alice/server.b");
// Returns: true on success
// Throws:
// PermissionError for permission-denied / reserved-port cases
// FileSystemError for missing or uncompiled script files
// RuntimeError for port conflicts or service-limit failures
var stopped = await os.StopService(8080);
// Returns: true if stopped, false if no service on that port
var installed = await os.InstallService("/srv/sshd.service");
// Parses and registers a .service config without directly starting it
var enabled = await os.EnableService("/srv/sshd.service");
var disabled = await os.DisableService("/srv/sshd.service");
// Edits the .service config autostart flag and resyncs runtime state
var cfgStarted = await os.StartServiceConfig("/srv/sshd.service");
// Parses a .service config, syncs it into runtime state, and starts it
var cfgStopped = await os.StopServiceConfig("/srv/sshd.service");
// Stops a service by config path
var removed = await os.UninstallService("/srv/sshd.service");
// Removes the runtime registration for a config-managed service
var one = await os.GetService("/srv/sshd.service");
// Returns: ServiceInfo or null
var services = await os.GetServices();
// Returns: array of { Name, Port, ConfigPath, ScriptPath, Autostart, RestartPolicy, IsBuiltin, IsRunning }
foreach (var svc in services) {
Print(svc.Name + " on port " + svc.Port + " running=" + svc.IsRunning);
}
var logs = await os.GetServiceLogs("/srv/sshd.service", 25);
foreach (var entry in logs) {
Print(entry.Level + " " + entry.EventType + " " + entry.Message);
}
// Terminal command: svc logs [service|config.service] [--lines <count>] [-f]
// svc logs β all service logs (last 20)
// svc logs sshd β logs for sshd service
// svc logs /srv/sshd.service β logs by config path
// svc logs sshd --lines 50 β last 50 entries
// svc logs sshd -f β follow (live tail)
// svc logs sshd --lines 50 -f β follow with custom limit
var auth = await os.Authenticate("alice", "secret");
if (auth != null) {
Print(auth.Username + " " + auth.Home);
}Ports reserved by built-in services (e.g. port 22 for sshd) cannot be bound by player services.
See Services for details on writing service scripts.
Process Management β
OSApi os = Device.GetOS();
var procs = await os.GetProcesses();
foreach (var p in procs) {
Print(p.Pid + " " + p.Cmd);
}
var p = await os.GetProcess(1234); // null if not found
await os.KillProcess(1234); // terminate a process
await os.DetachProcess(1234); // detach from terminal, keeps running in background
await os.Run("/Drive1/bin/tool.b"); // run an executableProcessInfo items expose these fields: Pid, Ppid, Tty, OwnerUser, OwnerGroup, Cmd, StartTime, ExecutionTier, AttachedSession, IsDetached, RunsInBackground.
Power Management β
OSApi os = Device.GetOS();
await os.Shutdown(); // power off the current device
await os.Restart(); // controlled power-cycle of the current deviceShutdown() and Restart() are OS-level operations. They are not filesystem methods. Restart() performs the device restart flow, including service/process cleanup on the way down and autostart service discovery on the way back up.
User and Group Management β
OSApi os = Device.GetOS();
// User operations
await os.SwitchUser("bob", "password");
await os.Ssh("10.0.0.5", "admin", "password", Program.GetWindowId()); // push SSH context in a terminal
await os.AddUser("charlie", "pass123");
await os.DeleteUser("charlie");
await os.EditUser("bob", "robert", "newpass");
var info = await os.Id("alice"); // UserInfo or null
// Group operations
var groups = await os.Groups("alice"); // string[] or null
var group = await os.GroupInfo("admins"); // GroupInfo or null
await os.GroupAdd("developers"); // create group
await os.GroupAddUser("developers", "alice");
await os.GroupRemoveUser("developers", "alice");
await os.GroupRename("developers", "devs");
await os.GroupDelete("devs");SwitchUser() now throws AuthenticationError for invalid username, invalid password, or missing home-directory auth failures instead of returning those result strings.
Authenticate(username, password) is a lower-level auth primitive for scripts and services. It returns AuthInfo on success or null for invalid credentials / missing users. This lets player-authored daemons own their auth flow without direct access to password hashes.
OSApi.Ssh(host, user, password, windowId[, port]) is the script-facing SSH operation. It requires an open local Terminal window id, pushes a remote terminal context on success, throws AuthenticationError for invalid credentials, throws NetworkError when the target cannot be reached or has no valid SSH service, and throws a runtime error if the supplied window id is not a usable local terminal window.
User/group management now follows the same contract as the rest of the modern API surface:
- permission/admin failures throw
PermissionError - invalid names/arguments and duplicate-name conflicts throw runtime errors with specific codes
- lookup methods like
Id(),Groups(username), andGroupInfo()returnnullwhen the requested target does not exist - mutators like
DeleteUser(),EditUser(),GroupAddUser(),GroupRemoveUser(),GroupRename(), andGroupDelete()returnfalsewhen the requested target does not exist
OSApi.Id(username) returns a UserInfo object with these fields:
| Field | Type | Description |
|---|---|---|
_type | string | Always "UserInfo" |
Uid | int or null | Numeric uid for the user |
Username | string | Username |
Groups | list | List of { Id, Name } group objects |
Text | string | Formatted summary similar to id output |
GroupInfo exposes Name and Members. ServiceInfo exposes Name, Port, ConfigPath, ScriptPath, Autostart, RestartPolicy, IsBuiltin, IsRunning, Pid, LastFailure, LastExitReason, RestartCount, LastStartedAt, and LastStoppedAt. ServiceInfo is runtime state returned by GetService() / GetServices(), not a parsed .service config object. ServiceLogInfo exposes ServiceName, ConfigPath, ScriptPath, Port, Level, EventType, Message, and CreatedAt.
Example:
OSApi os = Device.GetOS();
var info = await os.Id("alice");
if (info != null) {
Print(info.Username);
Print(info.Uid);
Print(info.Text);
foreach (var g in info.Groups) {
Print(g.Id + ":" + g.Name);
}
}Filesystem Access β
OSApi os = Device.GetOS();
var fs = os.GetFileSystem(); // returns the filesystem API handle
// Equivalent to Device.GetOS().GetFileSystem()
// See the Filesystem section under Device Global for full fs APITiming β
OSApi os = Device.GetOS();
await os.Sleep(1000);Window Handle β
Window handles are returned by OSApi.GetActiveWindow(), OSApi.GetOpenWindows(), OSApi.OpenWindow(), and OSApi.OpenNode(). They are thin proxies backed by server-side state.
Base Methods (all windows) β
| Method | Returns | Description |
|---|---|---|
GetId() | string | Stable window identifier for tracking/comparison |
GetTitle() | string | Async. Read the current window title. |
GetType() | string | One of: "Terminal", "Explorer", "TextEditor", "CodeEditor" |
GetPid() | int or null | Async. Server process ID backing the window. |
IsActive() | bool | Async. true if this window is currently focused. |
Focus() | bool | Async. Bring window to foreground (works on minimized windows). |
Close() | bool | Async. Close the window. Returns true on success, false if already closed. For Terminal windows, also terminates all active SSH contexts. |
Calling methods that require an open window on a closed handle throws a runtime error. Close() is idempotent and returns false if the window is already closed.
Terminal Window Methods β
Available on windows where GetType() returns "Terminal".
| Method | Returns | Description |
|---|---|---|
Execute(command) | string | Async. Execute command in the terminal's current context and return output. Works regardless of focus state. |
ExecuteAsync(command) | AsyncResult | Async. Start command execution and return a pollable result handle. |
GetOutput() | string | Async. Read the current terminal output buffer. |
Clear() | true | Async. Clear the terminal output buffer. |
SetCwd(path) | bool | Async. Change the terminal's working directory. Returns false if path doesn't exist. |
If the terminal is in an SSH session, Execute() runs the command on the remote device using the SSH session's user and permissions.
OSApi os = Device.GetOS();
var term = await os.OpenWindow("Terminal");
await term.Execute("ls /Drive1/home");
var output = await term.GetOutput();
Print(output);
await term.SetCwd("/Drive1/home/alice");
await term.Execute("cat notes.txt");
Print(await term.GetOutput());
await term.Clear();
await term.Close();ExecuteAsync() returns an AsyncResult handle with:
| Method | Returns | Description |
|---|---|---|
IsDone() | bool | true when execution has finished |
GetOutput() | string or null | Output once complete, otherwise null |
GetError() | string or null | Error message once complete, otherwise null |
Await() | string | Async. Wait for completion and return the final output, or throw on error |
Editor Window Methods β
Available on windows where GetType() returns "TextEditor" or "CodeEditor".
| Method | Returns | Description |
|---|---|---|
GetContent() | string | Async. Read the current editor content. |
SetContent(text) | true | Async. Replace the editor content with new text. |
GetFilePath() | string or null | Async. Absolute path of the open file, or null if no file is associated. |
Save() | bool | Async. Persist content to disk. Returns false on failure (e.g. permission denied, no file associated). |
OSApi os = Device.GetOS();
var win = await os.OpenNode("/Drive1/home/alice/script.k");
try {
if (win != null) {
var content = await win.GetContent();
await win.SetContent(content + "\n// modified by script");
await win.Save();
Print("Saved: " + await win.GetFilePath());
await win.Close();
} else {
Print("File not found");
}
} catch (PermissionError ex) {
Print(ex.Message);
}Terminal Commands β
SSH β
The ssh command is an executable /bin/ssh script that establishes an interactive remote session from a terminal. It switches the terminal's context (filesystem, user, CWD, prompt) to the remote device without affecting the DesktopUI or other terminal windows.
Syntax:
ssh <user> <host> <password>The script-backed command currently defaults to port 22. On success, the terminal prompt updates to reflect the remote device:
alice@my-pc:~$ ssh admin 10.0.0.5 secret
admin@remote-server:~$Behavior:
- All commands and filesystem operations in the SSH session execute on the remote device
- The DesktopUI, taskbar, and other windows remain unchanged (local device)
- Other terminal windows keep their own independent context
- A process entry is created on the remote device for the SSH session
- The target device must have a running sshd service on the specified port
Nested SSH: You can SSH from within an SSH session. Each ssh pushes a new context onto the terminal's context stack:
alice@my-pc:~$ ssh admin server-1 secret
admin@server-1:~$ ssh root server-2 hunter2
root@server-2:~$Error messages:
- Invalid credentials:
"Authentication failed." - Unreachable host or no sshd service:
"Connection refused."
exit β
The exit command pops the terminal's context stack:
- If in an SSH session: terminates the current SSH context, restores the previous context (local or outer SSH session), and updates the prompt.
- If at the base (local) context: closes the terminal window.
root@server-2:~$ exit
admin@server-1:~$ exit
alice@my-pc:~$ exit
(terminal window closes)When a terminal window is closed (by exit, Close(), or UI), all active SSH contexts for that terminal are terminated and remote session processes are cleaned up.
su (Switch User) β
The su command switches the active user context. Its behavior depends on whether the terminal is in a local or remote context:
Local (physical device):
alice@my-pc:~$ su bob password123
bob@my-pc:~$- Switches both the terminal context AND the DesktopUI
- Desktop updates to show the new user's desktop folder and home directory
- CWD changes to the new user's home directory
Remote (SSH session):
alice@remote:~$ su admin secretpass
admin@remote:~$- Switches only the terminal context (user, permissions, CWD, prompt)
- DesktopUI remains unchanged β still shows the local device's desktop
- Subsequent commands use the new user's permissions on the remote device
Error messages:
- Invalid username:
"su: user not found" - Wrong password:
"su: invalid password" - Missing home directory:
"su: home directory not found"
Services β
Devices can run background services that listen on ports and handle incoming connections. Services are either built-in (like sshd) or player-created.
Built-in Services β
sshd β The SSH daemon is automatically registered on port 22 when an OS is installed. It authenticates incoming SSH connections and Device.Connect() calls using the device's user credentials.
- Registered automatically on OS install, removed on OS uninstall
is_builtin = true, always auto-starts when the device powers on- Current runtime: built-in OS installs seed
/bin/sshd.band/srv/sshd.service - Current runtime: the built-in service row is synced from that service config and points at the daemon executable
- Current runtime: terminal SSH now consults the scripted
sshddaemon for auth/shell handoff when that daemon is present, while some lower-level connection/session pieces remain engine-owned - If sshd is stopped, both
sshandDevice.Connect()on port 22 will fail
Planned Linux-style split:
/bin/ssh.bβ SSH client command/bin/sshd.bor similar β daemon executable/srv/sshd.serviceβ service manager config
That service-config layer now exists in the runtime. The first lower-level daemon primitives also now exist: services can define OnRun(ServiceListener listener), accept inbound ServiceConnection objects explicitly, authenticate with OSApi.Authenticate(...), and request a shell handoff with connection.OpenShell(...).
Player Services β
Players can create custom services using KodaScript. A service script defines a public class with lifecycle methods:
public class MyServer {
// Called once when the service starts (optional)
public static void OnStart() {
Print("Service started!");
}
// Callback style: called for each incoming connection (optional)
public static void OnConnect(ServiceConnection connection) {
Print("Client connected: " + connection.RemoteIp);
connection.Send("Welcome!");
var msg = connection.Receive();
while (msg != null) {
Print("Received: " + msg);
connection.Send("Echo: " + msg);
msg = connection.Receive();
}
connection.Close();
}
// Daemon-loop style: long-lived listener/accept loop (optional)
public static async void OnRun(ServiceListener listener) {
while (listener.IsOpen()) {
var connection = await listener.Accept();
if (connection == null) {
return;
}
connection.Send("daemon " + connection.RemoteUser);
connection.Close();
}
}
// Called once when the service stops (optional)
public static void OnStop() {
Print("Service stopped.");
}
}Service classes do not use Main() as their entry point. Regular executables use Main(). Service scripts use lifecycle hooks instead:
OnStart()when the service is startedOnConnect(...)for each inbound connection in callback-style servicesOnRun(...)once as a long-lived daemon loop for explicit listener/accept servicesOnStop()when the service is stopped
Inbound service connection context (passed to OnConnect):
| Property/Method | Description |
|---|---|
RemoteIp | IP address of the connecting client (string) |
RemoteUser | Username of the connecting client (string) |
Id | Stable id for the current inbound service connection |
LocalPort | Bound service port handling this connection (int) |
ConnectedAt | ISO timestamp for when the inbound connection context was created |
Send(message) | Send a message string to the client |
Receive() | Wait for and return the next message from the client, or null on disconnect |
Close() | Terminate the connection from the service side |
Reject([message]) | Optionally send a final message, then close the connection |
IsClosed() | true if the connection has already been closed |
SetLabel(label) | Attach a service-defined label to the connection/session |
GetLabel() | Read the current service-defined label |
AddFlag(flag) | Attach a service-defined flag to this connection |
RemoveFlag(flag) | Remove a previously attached flag |
HasFlag(flag) | true if the flag is currently set |
GetFlags() | Returns the current connection flags as string[] |
ClearFlags() | Clear all service-defined flags on this connection |
StartSession(auth[, label]) | Start an authenticated service session from an AuthInfo object |
GetSession() | Returns the current ServiceSessionInfo, or null if not authenticated |
ClearSession() | Clears the current authenticated service session |
IsAuthenticated() | true if the connection currently has a service session |
RecordAttempt([success[, reason]]) | Record an auth/session attempt and return updated rate-limit info |
GetRateLimit() | Returns the current ServiceRateLimitInfo for this connection |
SetThrottle(delayMs[, rejectAfterFailures]) | Configure simple per-connection throttling |
GetThrottle() | Returns the current ServiceThrottleInfo |
IsThrottled() | true if the connection is currently marked throttled |
SetPeerThrottle(rejectAfterFailures[, cooldownMs]) | Configure shared peer blocking for this service keyed by remote IP/user |
GetPeerThrottle() | Returns the current ServicePeerThrottleInfo for this remote peer |
RecordPeerAttempt([success[, reason]]) | Record a shared attempt for this peer and return updated peer state |
GetPeerState() | Returns the current ServicePeerStateInfo for this remote peer |
IsPeerBlocked() | true if this remote peer is currently blocked for this service |
LogEvent(level, eventType, message) | Async. Append a structured service log entry with connection/session details |
OpenShell(username) | Request a shell/session handoff for the authenticated user |
This inbound service connection context is a ServiceConnection. It is not the same object as the outbound Connection returned by Device.Connect(...). It is a service-side connection object used only inside OnConnect(...).
StartSession(auth[, label]) and OpenShell(username) have different roles:
StartSession(...)marks the inbound service connection as authenticated and is enough for scripted daemon auth that backsDevice.Connect(...)OpenShell(username)requests an interactive shell/session handoff for terminal-style SSH flows
In the current runtime, OpenShell(username) records a shell handoff request for the host runtime to fulfill. Combined with OSApi.Authenticate(...) and StartSession(...), this is the current path toward more script-owned daemons.
IsThrottled() is per-connection state. IsPeerBlocked() is shared per service and keyed by RemoteIp|RemoteUser, so repeated failures across many inbound connections can still block the same peer.
Daemon listener context (passed to OnRun):
| Property/Method | Description |
|---|---|
Port | Bound service port (int) |
IsOpen() | true while the listener is open |
Accept() | Async; returns the next inbound ServiceConnection, or null when the listener closes |
Close() | Closes the listener and wakes pending Accept() calls |
ServiceListener is a service-side daemon object. It is not a general socket API and is only injected into service scripts that define OnRun(ServiceListener listener).
Accept() is not an infinite raw-socket wait. In the current runtime it can also resolve null after a host timeout so long-lived daemon loops can wake up, re-check listener.IsOpen(), and continue or exit cleanly.
Authenticated service session info (returned by connection.StartSession(...) / connection.GetSession()):
| Property | Description |
|---|---|
Username | Authenticated username |
Uid | Device UID |
Home | Home path |
IsRoot | Whether the authenticated user is root |
Label | Service-defined session label |
StartedAt | ISO timestamp for when the service session started |
Per-connection rate-limit info (returned by connection.RecordAttempt(...) / connection.GetRateLimit()):
| Property | Description |
|---|---|
Attempts | Total attempts recorded on this connection |
FailedAttempts | Failed attempts recorded on this connection |
LastAttemptAt | ISO timestamp for the last recorded attempt |
LastFailureAt | ISO timestamp for the last recorded failure |
LastFailureReason | Service-defined failure reason |
Per-connection throttle info (returned by connection.SetThrottle(...) / connection.GetThrottle()):
| Property | Description |
|---|---|
DelayMs | Service-defined delay/cooldown for this connection |
RejectAfterFailures | Service-defined failure threshold before the connection is considered throttled |
IsThrottled | true once the configured failure threshold has been reached |
Per-peer shared state (returned by connection.RecordPeerAttempt(...) / connection.GetPeerState()):
| Property | Description |
|---|---|
PeerKey | Stable service-local peer key (`RemoteIp |
RemoteIp | Remote IP tracked for this peer |
RemoteUser | Remote username tracked for this peer |
Attempts | Total attempts recorded for this peer on this service |
FailedAttempts | Failed attempts recorded for this peer on this service |
LastAttemptAt | ISO timestamp for the last recorded peer attempt |
LastFailureAt | ISO timestamp for the last recorded peer failure |
LastFailureReason | Service-defined reason from the last peer failure |
CooldownUntil | ISO timestamp until which the peer remains blocked, or empty string |
IsBlocked | true if the peer is currently blocked for this service |
Per-peer throttle config (returned by connection.SetPeerThrottle(...) / connection.GetPeerThrottle()):
| Property | Description |
|---|---|
RejectAfterFailures | Failure threshold before this peer is blocked for the service |
CooldownMs | Shared cooldown duration applied once the peer is blocked |
IsBlocked | true if this peer is currently blocked |
Example:
public class LoginService {
public static async void OnConnect(ServiceConnection connection) {
connection.SetThrottle(500, 3);
connection.SetPeerThrottle(3, 30000);
connection.AddFlag("honeypot");
if (connection.LocalPort != 22) {
connection.Reject("wrong port");
return;
}
if (connection.IsPeerBlocked()) {
connection.Reject("peer blocked");
return;
}
connection.Send("login:");
var user = connection.Receive();
connection.Send("password:");
var pass = connection.Receive();
var auth = await Device.GetOS().Authenticate(user, pass);
if (auth == null) {
var peer = connection.RecordPeerAttempt(false, "bad password");
await connection.LogEvent("warn", "honeypot_auth_failed", "failed login for " + user);
var rl = connection.RecordAttempt(false, "bad password");
if (connection.IsPeerBlocked()) {
connection.Reject("blocked for 30s after " + peer.FailedAttempts + " failures");
return;
}
if (connection.IsThrottled()) {
connection.Reject("too many attempts");
return;
}
if (rl.FailedAttempts >= 3) {
connection.Reject("too many attempts");
return;
}
connection.Send("auth failed");
connection.Close();
return;
}
connection.RecordPeerAttempt(true);
connection.RecordAttempt(true);
connection.SetLabel("ssh-auth");
var session = connection.StartSession(auth, "shell");
await connection.LogEvent("info", "honeypot_session_opened", "session opened for " + session.Username);
connection.Send("welcome " + auth.Username);
connection.OpenShell(auth.Username);
}
}Built-in sshd now uses the daemon-loop style internally:
public class SshdService {
public static async void OnRun(ServiceListener listener) {
while (listener.IsOpen()) {
var connection = await listener.Accept();
if (connection == null) {
return;
}
var user = connection.Receive();
var pass = connection.Receive();
var auth = await Device.GetOS().Authenticate(user, pass);
if (auth == null) {
connection.RecordPeerAttempt(false, "invalid credentials");
connection.RecordAttempt(false, "invalid credentials");
connection.Reject("authentication failed");
continue;
}
connection.RecordPeerAttempt(true);
connection.RecordAttempt(true);
var session = connection.StartSession(auth, "shell");
connection.OpenShell(session.Username);
connection.Close();
}
}
}That built-in flow demonstrates both successful scripted paths:
StartSession(...)authenticates the daemon-side connection and is enough forDevice.Connect(...)OpenShell(...)adds the interactive shell handoff used by terminal SSH
Starting and stopping services (current runtime):
// Compile the service script first, then start it
OSApi os = Device.GetOS();
try {
await os.StartService(8080, "/Drive1/home/alice/server.b");
Print("Service running on port 8080");
} catch (Exception ex) {
Print("Failed: " + ex.Message);
}
// List running services
var services = await os.GetServices();
foreach (var svc in services) {
Print(svc.Name + ":" + svc.Port + " builtin=" + svc.IsBuiltin + " running=" + svc.IsRunning);
}
// Install/start/inspect by service config path
await os.InstallService("/srv/sshd.service");
await os.EnableService("/srv/sshd.service");
await os.StartServiceConfig("/srv/sshd.service");
var sshd = await os.GetService("/srv/sshd.service");
if (sshd != null) {
Print(sshd.ConfigPath + " -> " + sshd.ScriptPath);
}
// Stop the service
await os.StopService(8080);
await os.StopServiceConfig("/srv/sshd.service");
await os.DisableService("/srv/sshd.service");
await os.UninstallService("/srv/sshd.service");Service rules:
- Each connection invokes
OnConnectin an isolated execution context (one slow connection won't block others) OnRun(ServiceListener listener)is optional and lets a service own an explicit accept loop instead of only relying onOnConnect(...)- If
OnConnectthrows a runtime error, that individual connection is closed but the service keeps running - Maximum 5 player services per device (configurable)
- Built-in service ports (e.g. 22) cannot be bound by player services
- When a device powers off, all running services are stopped
- Built-in services auto-start on power-on
- Config-backed services with
autostart=trueare also discovered on power-on and marked running - Services with
restart=alwaysorrestart=on-failureare restarted automatically after process exit - Automatic restarts use bounded exponential backoff and update runtime state like
RestartCount,LastExitReason, andLastFailure /srv/*.serviceconfigs are now discovered during OS install/power-on flows and synced into runtime service rows
StartService Failures β
| Outcome | Meaning |
|---|---|
true | Service started successfully |
PermissionError | Current user lacks permission, or the port is reserved by a built-in service |
FileSystemError | Script file does not exist or is not a compiled .b executable |
RuntimeError | Port already in use or device has reached the maximum number of player services |
Current explicit executable-path behavior: StartService(port, path) still starts a service directly from a compiled executable path, so service scripts are not limited to /bin. The conventional location in the current runtime is /srv, for example:
OSApi os = Device.GetOS();
await os.StartService(8080, "/srv/webserver.b");/srv is just a convention for explicit path starts. Any executable compiled .b path can be used if permissions allow it.
Config-driven service behavior: The current game/server build now has a real .service discovery layer. /srv/*.service files are parsed during install/power-on flows and synced into device_services. StartService(port, path) remains the lower-level executable-path API, while the config-driven manager APIs operate on registered .service definitions.
Current manager-style service API: The current build now also exposes:
InstallService(configPath)EnableService(configPath)DisableService(configPath)StartServiceConfig(configPath)StopServiceConfig(configPath)UninstallService(configPath)GetService(configPath)GetServiceLogs(configPath, limit)Sleep(ms)
These work with .service files directly and sit alongside the lower-level StartService(port, path) executable-path API. EnableService() and DisableService() edit the config file's autostart= value, then resync the runtime row.
For the seeded /bin/svc command, logs accepts either flag order:
svc logs sshd --lines 50 -fsvc logs sshd -f --lines 50svc logs --lines 100 -f
API Ownership Boundary β
The API is split between Device (hardware/network) and OS (operating system):
| Concern | Owner | Examples |
|---|---|---|
| Hardware components | Device | GetComponent(), GetComponents() |
| Network connectivity | Device | Connect(), Probe(), Load() |
| Device type | Device | GetType() |
| OS access | Device | GetOS() |
| Windows | OSApi | GetActiveWindow(), OpenWindow(), OpenNode() |
| Processes | OSApi | GetProcesses(), KillProcess(), Run() |
| Power | OSApi | Shutdown(), Restart() |
| Services | OSApi | StartService(), InstallService(), EnableService(), DisableService(), StartServiceConfig(), StopService(), StopServiceConfig(), UninstallService(), GetService(), GetServices(), GetServiceLogs(), Sleep() |
| Filesystem | OSApi | GetFileSystem() β fs API |
| Users/Groups | OSApi | SwitchUser(), AddUser(), Groups(), etc. |
Connection objects (from Device.Connect()) provide remote access to filesystem, processes, components, and libraries. They do not expose window or service management methods β calling those on a Connection throws a runtime error (R101).
Command Resolution β
Bare terminal command names resolve from /bin only.
- Typing
sshin a terminal resolves/bin/ssh - Removing
/bin/sshmeans the baresshcommand no longer exists - Scripts can still be executed from anywhere by explicit path
Examples:
await os.Run("/bin/ssh.b"); // explicit path
await os.Run("/srv/webserver.b"); // explicit path outside /bin/bin is the shell command path. It is not the only location where executable scripts may live.
Error Contract β
KodaScript APIs use consistent return/error semantics:
| Pattern | When Used | Examples |
|---|---|---|
true / false | Success/failure operations | Close(), Save(), StopService() |
null | Not found / no object | GetActiveWindow(), GetProcess(pid), OpenWindow("invalid") |
| Object / handle | Successful queries | GetOpenWindows(), OpenWindow("Terminal") |
| String error code | APIs that still use result-code returns | |
| Runtime error | Misuse / invalid state | Calling methods on closed windows, calling OS methods on Connection objects |
String error codes (only for APIs that still use result-code returns):
| Code | Context |
|---|
Guideline for scripts:
OSApi os = Device.GetOS();
try {
await os.StartService(8080, "/Drive1/home/alice/server.b");
Print("Started!");
} catch (Exception ex) {
Print("Error: " + ex.Message);
}Error Codes β
| Code | Meaning |
|---|---|
| E000 | Generic parse/compile error (unclassified syntax error) |
| E001 | Top-level class must be public |
| E002 | At least one top-level class required |
| E003 | .h files cannot have a Main() method |
| E005 | Cannot compile a .h file directly |
| E006 | #include target must be a .h file |
| E007 | Included file not found |
| E008 | Circular #include detected |
| E009 | #include must appear before any class definition |
| E010 | Nested class declarations are not allowed |
| E011 | Only one Main() allowed per program |
| E013 | Constructor must not declare a return type |
| E014 | Constructor name must match the class name |
| E015 | Only one constructor allowed per class |
| E016 | Missing semicolon after statement |
| E017 | var is not valid as a field type or method return type |
| E018 | Duplicate class/method/field name after #include merge |
| E019 | args referenced outside Main() |
| E020 | Cannot #include a .so or .b file |
| E021 | Bare # is not valid syntax β use // for comments |
| E022 | Block comments /* */ are not supported |
| E023 | Top-level class cannot be static |
| E024 | Main() must be public static void |
| E025 | await can only be used inside an async method |
| E026 | Async value used without required await |
| E027 | Method does not exist on a statically-known type |
| E028 | Wrong number of arguments for a statically-known call |
Compiler Warnings β
| Code | Meaning |
|---|---|
| W001 | Field declared without access modifier β defaults to private |
| W002 | Method declared without access modifier β defaults to private |
| W006 | Constructor is private |
| W007 | #include is present but the included class is never used |
| W008 | A statically-known API call that may return null or an error string is assigned directly into a concrete declared type |
| W009 | A previously stored errorable API result is used or assigned as a concrete value before an obvious narrowing check |
Warnings allow compilation to succeed. They appear alongside the build result.
Runtime Error Codes β
Runtime errors use the R family and currently default to:
| Code | Meaning |
|---|---|
| R000 | Generic runtime error |
| R001 | Undefined identifier/symbol |
| R002 | Null reference access |
| R003 | Arithmetic divide/modulo by zero |
| R004 | Invalid runtime operation/type use |
| R005 | Invalid assignment/index/member mutation |
| R006 | Access/context violation (private/static/this) |
| R007 | Runtime limits reached (step/recursion/abort) |
| R101 | Connection not open / not connected / unsupported method on Connection (e.g. window/service methods) |
| R102 | Permission denied in runtime API |
| R103 | Library load failure (not found/invalid type) |
| R104 | Library access violation |
Runtime error objects expose error.code (for example in engine-side handlers/panels).
Limits β
| Limit | Value |
|---|---|
| Max string length | 1 MB (1,048,576 characters) |
| Max execution steps | Configurable per script run |
Exceeding the string limit during concatenation, interpolation, or Join() throws a runtime error.